PI-Desktop Approval Modes: Tool vs Contract Permissions Explained

PI-Desktop implements two distinct approval families—tool-level requests offering decisions like allow-once and allow-session, and contract-level requests requiring approve or reject decisions paired with permission modes such as read, write, or admin.

PI-Desktop (vastsa/PI-Desktop) is an agent host framework that governs how AI tools and planning contracts receive user authorization. Understanding the approval modes in PI-Desktop is essential for developers integrating custom tools or managing plan proposals, as the system distinguishes between transient tool permissions and persistent contract access levels using a typed broker architecture.

The Two Families of Approval Requests

The codebase separates approval logic into tool-level and contract-level families, each with distinct decision sets and permission requirements defined in packages/shared/src/racp.ts.

Tool-Level Requests

Tool-level approvals govern individual tool executions. When a tool requests permission—for example, to run a shell command or fetch external data—the system creates a RacpApprovalRequest via ApprovalBroker.fromToolPermission.

Users choose from three decision values:

  • allow-once – Permits the action a single time only
  • allow-session – Permits the action for the duration of the current session
  • deny – Blocks the action entirely

Tool requests do not utilize permission modes; access is binary based on the decision selected.

Contract-Level Requests

Contract-level approvals handle plan or goal proposals. These are generated via ApprovalBroker.fromPlanningState and present users with contract approval decisions:

  • approve – Accepts the proposal
  • reject – Declines the proposal

Unlike tool requests, contract approvals require a permission mode when approved. The available values are exported as RACP_PERMISSION_MODES (e.g., read, write, admin) and are presented to the user through the PlanApprovalBar component in the desktop UI.

The Approval Request Lifecycle

The approval flow follows a strict six-phase pipeline managed by the ApprovalBroker class in packages/agent-host/src/approvals.ts.

Phase 1: Request Creation

The broker instantiates RacpApprovalRequest objects differently depending on the source:

  • Tool requests: Created by ApprovalBroker.fromToolPermission, populating allowedDecisions based on the tool's risk profile and session settings
  • Plan/goal requests: Created by ApprovalBroker.fromPlanningState, populating both allowedDecisions (["approve","reject"]) and allowedPermissionModes (from RACP_PERMISSION_MODES)

Phase 2: UI Presentation

The PlanApprovalBar component in apps/desktop/src/components/PlanApprovalBar.tsx renders the request to the user. For tool requests, it displays decision buttons. For contract requests, it shows a dropdown labeled "Choose approval mode" containing the permitted permission modes alongside approve/reject options.

Phase 3: User Selection

For tools, the user selects a decision value. For contracts, selecting approve requires choosing a permission mode (e.g., "write"), while reject requires none. The UI constructs a RacpApprovalResponse containing approvalId, decision, and optionally permissionMode.

Phase 4: Resolution and Validation

ApprovalBroker.resolve validates the response against the stored request:

  • Verifies the decision exists in allowedDecisions
  • Validates the permission mode against allowedPermissionModes (for contracts)
  • Checks the request has not expired using expiresAtMs
  • Validates the expectedRevision to prevent stale approvals

Upon validation, the broker calls the ApprovalPort implementation:

  • port.resolveTool for tool decisions
  • port.resolveContract for contract decisions (passing the selected permission mode)

Phase 5: Result Recording

The broker records the outcome in its internal results map, removes the pending request, and returns a RacpApprovalResult. The result status is set to resolved, canceled, or expired. Subsequent responses for the same approval ID receive status alreadyResolved.

Phase 6: Expiration and Cancellation

Approvals carry a TTL defined by lifetimeMs. If expiresAtMs passes before resolution, ApprovalBroker automatically settles the request with status expired. When a session ends, cancelForSession closes all pending approvals for that session with status canceled.

Code Implementation Examples

Creating Tool Approval Requests

// Inside the agent host (packages/agent-host/src/approvals.ts)
const toolRequest: ToolPermissionRequest = {
  requestId: "t123",
  sessionId: "s1",
  toolName: "curl",
  reason: "fetch external data",
  risk: "network",
};

const approval = approvalBroker.fromToolPermission(toolRequest, {
  turnId: "turn42",
  revision: 3,
  lifetimeMs: 5 * 60_000,      // 5 minutes TTL
  allowSession: false,          // Restricts to allow-once or deny only
});

When allowSession is false, approval.allowedDecisions contains ["allow-once","deny"]. When true, it includes "allow-session".

Creating Plan Approval Requests

const planningEvent: PlanningStateEvent = {
  sessionId: "s1",
  state: "awaiting_approval",
  kind: "plan",
  proposalId: "p99",
  title: "Deploy new feature",
  markdown: "...",
  version: 2,
};

const approval = approvalBroker.fromPlanningState(planningEvent, {
  turnId: "turn42",
  revision: 5,
  lifetimeMs: 10 * 60_000,   // 10 minutes TTL
});

This populates approval.allowedDecisions with ["approve","reject"] and approval.allowedPermissionModes with values exported as RACP_PERMISSION_MODES (e.g., ["read","write","admin"]).

Resolving Approval Responses

const response: RacpApprovalResponse = {
  approvalId: "p99",
  decision: "approve",
  permissionMode: "write",
  context: { expectedRevision: 5 },
};

const result = await approvalBroker.resolve(response, principal, currentRevision);

The broker validates the revision, invokes port.resolveContract with the "write" permission mode, and returns a RacpApprovalResult with status resolved.

Core Architecture and Source Files

The approval mode system spans four critical files:

Summary

  • PI-Desktop distinguishes tool-level approvals (allow-once, allow-session, deny) from contract-level approvals (approve/reject plus permission modes)
  • The ApprovalBroker class in packages/agent-host/src/approvals.ts manages the complete lifecycle from creation to resolution
  • Tool requests use fromToolPermission and resolve via port.resolveTool without permission modes
  • Contract requests use fromPlanningState and resolve via port.resolveContract with mandatory permission modes from RACP_PERMISSION_MODES
  • All approvals enforce TTL expiration through expiresAtMs and support session-based cancellation via cancelForSession

Frequently Asked Questions

What are the available decision values for tool permission requests?

Tool permission requests support three decisions defined in RACP_TOOL_APPROVAL_DECISIONS: allow-once (single execution), allow-session (session-wide access), and deny (blocking the action). The available choices depend on the allowSession parameter passed to ApprovalBroker.fromToolPermission.

How do permission modes work for contract approvals?

When a user approves a contract-level request (plan or goal), they must select a permission mode from the RACP_PERMISSION_MODES array (e.g., read, write, admin). This value is passed through port.resolveContract and determines the access level granted to the approved plan. The available modes are defined centrally in packages/shared/src/racp.ts and exposed via the PlanApprovalBar UI component.

Where is the approval logic implemented in the PI-Desktop codebase?

The core logic resides in packages/agent-host/src/approvals.ts within the ApprovalBroker class. This file handles request creation via fromToolPermission and fromPlanningState, validation in the resolve method, and lifecycle management including expiration and cancellation. The type definitions live in packages/shared/src/types/plans.ts and packages/shared/src/racp.ts.

What happens when an approval request expires?

If the TTL (lifetimeMs) elapses before user action, the ApprovalBroker automatically settles the request with status expired. The broker maintains the expiresAtMs timestamp and removes expired requests from the pending queue. Expired approvals return a RacpApprovalResult with status expired and cannot be resolved afterward.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →