OpenCode Permission Events: Build a Mobile Approval Queue Instead of Polling Session Permissions

OpenCode permissions are useful when you are driving agents from a terminal. They become awkward when the human approver is not sitting at that terminal. A better shape for remote approval is event-driven: subscribe to permission.asked, send the request to a mobile queue, then handle permission.replied as the audit trail.

This article is for teams wiring OpenCode into shared agent runners, dev boxes, or "approve from phone" workflows.

The short version

  • OpenCode's permission system resolves tool actions to allow, ask, or deny.
  • Plugins expose permission.asked and permission.replied events, which are a better integration point than polling session state.
  • The mobile queue should store the requested tool, input summary, session, worktree, and suggested "always" scope.
  • Never approve by broad category from a phone; approve the narrow request or a bounded pattern.
  • Treat permission replies as security events and keep an append-only audit log.

Why polling is the wrong abstraction

Polling session permissions sounds simple: every few seconds, ask the OpenCode server whether a session is waiting for approval. In practice it creates three problems.

First, approvals are edge-triggered. The important moment is not "what is the current permission state?" but "a specific tool invocation is blocked until a human answers." Polling risks stale UI, duplicate notifications, and race conditions between terminal and mobile decisions.

Second, permission prompts carry context. OpenCode permissions are keyed by tools such as read, edit, bash, webfetch, websearch, task, skill, and guards such as external_directory and doom_loop. For granular permissions, the matched input matters: a bash request for git status --porcelain is not the same as rm -rf build.

Third, OpenCode's UI can offer once, always, or reject. The dangerous case is always, because it approves a pattern for the rest of the session. A mobile approval surface must show the pattern clearly, not just the original command.

Use plugin events as the boundary

OpenCode plugins can subscribe to events including permission.asked and permission.replied. A queue integration should be a plugin that forwards the ask event to your backend and records the reply event for auditing.

The architecture is straightforward:

OpenCode session
  -> plugin: permission.asked
  -> approval API
  -> push notification / mobile app
  -> user approves once, always, or rejects
  -> OpenCode receives decision through your integration path
  -> plugin: permission.replied
  -> audit log

Keep the plugin small. It should not contain product policy. Its job is to serialize the event, authenticate to your approval service, and fail safely if the service is unavailable.

A queue item should include at least:

{
  "sessionId": "ses_...",
  "projectDirectory": "/repo/app",
  "worktree": "/repo/.worktrees/agent-42",
  "tool": "bash",
  "inputSummary": "npm test -- --runInBand",
  "rawInputHash": "sha256:...",
  "requestedAt": "2026-05-13T10:15:00Z",
  "decisionOptions": ["once", "always", "reject"],
  "suggestedAlwaysPattern": "npm test*"
}

Do not put secrets or full file contents in push notifications. Store full details server-side, redact aggressively in the notification body, and require app unlock before displaying the request.

Design the mobile approval screen for mistakes

A mobile UI makes approvals faster, but it also makes accidental approvals easier. Optimize for clarity over speed.

Good approval screens show:

  • the repository and branch/worktree;
  • the agent or session name;
  • the tool being requested;
  • a normalized command or path;
  • whether the target is outside the working directory;
  • the exact scope of always if offered;
  • recent related approvals in the same session.

For bash, show the parsed command and the raw command. For edit, show paths and a diff summary, not just "edit requested." For external_directory, show why the path is outside the project root and whether it is under an allowlisted parent.

A useful rule: the "approve always" button should be harder to tap than "approve once." If the pattern is broad, require a second confirmation.

Policy belongs on the server

The mobile app should not be the policy engine. Put policy in a server component that can reject requests before humans see them.

Examples:

hard_deny:
  bash:
    - "rm -rf /*"
    - "curl * | sh"
  edit:
    - "**/.env"
    - "**/id_rsa"
require_human:
  bash:
    - "git push*"
    - "gh pr merge*"
  external_directory:
    - "*"
auto_allow:
  bash:
    - "git status*"
    - "npm test*"

Keep this separate from OpenCode's own project permissions. OpenCode remains the local enforcement layer; your approval service is an organizational control plane.

Gotchas

Event delivery must be idempotent. Use a stable event ID or derive one from session, tool call, and timestamp. Push notifications can be duplicated.

Handle terminal replies. If a developer approves in the terminal while the mobile request is open, close the mobile card when permission.replied arrives.

Expire requests. A permission prompt from 30 minutes ago may no longer reflect the repository state. Use short TTLs and force the agent to ask again.

Audit rejections too. Rejections reveal attempted risky behavior and bad prompts.

We're building Grass with this human-in-the-loop shape in mind: agents can do the work in a sandbox, but the important decisions still come back to people in a reviewable form. If that is the workflow you want around remote coding agents, you can try Grass at https://codeongrass.com.

Conclusion

For remote OpenCode approvals, treat permissions as events, not state to poll. Subscribe to permission.asked, present a narrow and understandable mobile decision, and record permission.replied as an audit event. The result is faster human-in-the-loop control without weakening the permission boundary.

Sources

  • OpenCode permissions documentation
  • OpenCode plugins documentation, especially permission.asked and permission.replied events