Build
Permissions
Bind each approval to one exact action, resource and time window — and let your agent refuse everything else.
A permission widget records that the user said yes. The permission policy goes further: it ties that yes to a precise operation and only lets your code run that operation while the grant is valid.
Use it whenever your agent can touch something that matters — files, commands, accounts, money.
How it works
- Each request gets a unique widget ID and describes an exact action, resource and
parameters — for example
read_fileon/home/me/report.txtinutf8. - The approval is bound to the user, the conversation and a named session with an expiry. Answers must arrive through the SDK's encrypted channel.
- Your code runs the operation through
runAuthorized(run_authorized). The policy refuses anything that doesn't match the grant exactly.
| Scope | Meaning |
|---|---|
once | One attempt. Consumed before your callback starts — even if it fails. |
session | Repeated attempts at the same operation, resource and parameters until the grant expires, is revoked or the session ends. |
Grants live in memory. A restart requires new consent — that's intentional.
Example: read one file, with approval
import { readFile, realpath } from "node:fs/promises";
import { PermissionPolicy } from "@pacerelle/sdk/permissions";
const session = {
id: crypto.randomUUID(),
label: "Reading local reports",
expiresAt: new Date(Date.now() + 30 * 60_000), // 30 minutes
};
const permissions = new PermissionPolicy({ session });
const pending = new Map(); // widget ID → requested operation
client.onMessage(async (message, agent) => {
const send = (text) =>
agent.sendMessage({
conversationId: message.conversationId,
to: message.from,
toDeviceId: message.fromDeviceId,
text,
});
// Keeps the card in the app in sync with what the policy actually did.
const report = (ref) => send(JSON.stringify(permissions.createWidgetUpdate(ref)));
// 1. Is this an answer to one of our requests?
const decision = permissions.receiveResponse(message);
if (decision.status === "denied" || decision.status === "revoked") {
await report(decision.requestId);
return;
}
if (decision.status === "granted") {
const task = pending.get(decision.grant.requestId);
if (!task) return; // never infer an operation from the answer itself
await report(decision.grant.requestId);
try {
const text = await permissions.runAuthorized(
decision.grant.id,
{ ...task, userId: message.from, conversationId: message.conversationId, sessionId: session.id },
(allowed) => readFile(allowed.resource, "utf8"),
);
await send(`Done — the report is ${text.length} characters long.`);
} finally {
await report(decision.grant.requestId);
}
return;
}
if (message.widgetResponse) return; // unrelated, replayed or invalid answer
// 2. A request that needs permission.
if (message.text !== "Read the report") return;
const task = {
action: "read_file",
resource: await realpath("./report.txt"), // resolve before asking
parameters: { encoding: "utf8" },
};
const widget = permissions.createRequest({
...task,
conversationId: message.conversationId,
userId: message.from,
title: "Read this report?",
body: "The agent will read only this file, as text.",
expiresAt: new Date(Date.now() + 5 * 60_000),
scopes: ["once", "session"],
});
pending.set(widget.id, task);
await send(JSON.stringify(widget));
});Send the widget returned by createRequest as the text of a message, as above. The simpler
sendPermissionWidget helper does not attach the policy's authorization details.
Revoke and end sessions
- The user can revoke a grant from the app after approving it.
receiveResponsechecks the author and conversation, then blocks future use. It can't stop an operation that already started. - In code,
revoke(grantId)withdraws one grant,endSession()(end_session()) withdraws them all, andlistGrants()(list_grants()) shows what's active.
Rules of thumb
- Resolve before you ask. Turn aliases and relative paths into the real resource first, and
include every argument that changes the effect in
parameters. - Never take the operation from the answer. Look it up from your own
pendingmap. - Keep sessions short. Minutes, not days. There's no "forever" scope by design.
- Report honestly. An approval is not proof that the operation succeeded — tell the user what actually happened.