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_file on /home/me/report.txt in utf8.
  • 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.
ScopeMeaning
onceOne attempt. Consumed before your callback starts — even if it fails.
sessionRepeated 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. receiveResponse checks 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, and listGrants() (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 pending map.
  • 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.