Repository navigation
Approval Channels
An approval is a durable row, and until v0.3.1 deciding one meant reaching the SQLite file: the operator ran agentrt approve on the host that held it. Real operators are in a chat room or a browser. Two core packages, approver and approver/webhook, close that gap without loosening what an approval is. A channel shows an approval, takes a decision bound to the hash it showed, and never touches the database itself; the decision is the runtime's own, made through the same code cmd/agentrt uses.
approver is the interface a channel is written over and the implementation of it that works on a store. approver/webhook is the reference channel, an http.Handler with a signed protocol, which a chat bot or a browser page talks to. Both use only the standard library, the core, view, and trace, so they add no dependency to the core. examples/approver runs the whole exchange with no model and no network beyond loopback.
Nothing here resumes a run. Resuming continues the loop, and that needs the consumer's agent, tools, and policy, so it stays in the consumer's own process (Driver.Resume). A granted approval waits there until it is resumed or, with Limits.GrantTTL, expires. See Approvals and Resume.
type Approver interface {
Pending(ctx context.Context, runID string, limit int) ([]Pending, error)
Show(ctx context.Context, runID, approvalID string) (Pending, error)
Approve(ctx context.Context, d Decision) error
Reject(ctx context.Context, d Decision) error
Cancel(ctx context.Context, runID, by, note string) error
}Every method addresses a run by id. There is no listing across runs, so a channel that holds one run's id learns nothing about another's.
-
Pendingreturns at mostlimitof a run's pending approvals in creation order when the run is waiting for approval; an empty list for a run that is not waiting, since nothing on it can be decided; andagentrt.ErrNotFoundfor a run that does not exist. -
Showreads one approval of a run, whatever its status, for presenting;agentrt.ErrNotFoundwhen the run has no such approval. -
Approvegrants it. The run stays waiting until its owner resumes it. -
Rejectrefuses it, which ends the run asCANCELLED. -
Cancelends a run that is not terminal, waiting or not, without deciding its approvals.
Pending is an approval as a channel presents it: the stored approval with its capability, presentation, and arguments bounded the way cmd/agentrt bounds them (view.BoundApproval), the policy's reason for asking, and whether its expiry has passed. The reason comes from Store.ApprovalReason, which reads the reason of the policy decision that paused the run from the approval's approval.requested event, so a channel can show why the policy asked, even after a resume-time policy decided otherwise; it is bounded as an argument string is and may carry text the model chose. Expired is true when ExpiresAt has passed by the clock at the read; nothing is written, and the runtime expires the approval, and cancels the run, when it is next decided. Hash is the hash of the stored approval, which a decision is bound to. The bounding changes what is shown, never what is hashed.
Decision is what a channel supplies: RunID, ApprovalID, Hash (the hash of the approval as it was shown), By, and Note. A decision whose hash differs from the stored approval is refused with agentrt.ErrApprovalChanged; one with no hash at all is refused the same way. A decision with no run, no approval, or no identity is approver.ErrDecision. By is a label, recorded exactly as given and never verified.
approver.Local is the Approver over a store this process can open. It is the runtime's own driverless decisions, agentrt.Operator, so Approve is ApproveShown and Reject is RejectShown, and expiry is judged by the wall clock unless Local.Now is set before use, which a channel's tests do to put an approval before or past its expiry.
a := approver.New(store, observer) // over a store the caller opened and closes
a, err := approver.Open("runs.db", observer) // opens the database through OpenExisting(path, false); Close closes itOpen needs the database to exist already, at this build's schema version, and not be a symbolic link. The observer, which may be nil, receives the runtime's events for each decision after commit. Pending reads through ListApprovalsPage, so no row is read whole: an argument object over MaxPageText is read as its size, and DecodeError says so. Show reads the one row whole, as cmd/agentrt reads the one it decides, and bounds it for showing.
Errors are the runtime's own, wrapped so errors.Is matches them: ErrNotFound, ErrRunState, ErrNotPending, ErrApprovalChanged, and ErrApprovalHash, plus two from this package. approver.ErrExpired means the approval had passed its expiry when the decision arrived: the runtime expired it and cancelled the run as approval_expired. An error matching it also matches the core's agentrt.ErrApprovalExpired, which in turn matches agentrt.ErrNotPending, which an expired approval has always returned. See Approvals and Resume.
h, err := webhook.New(webhook.Config{Approver: approver.New(store, nil), Secret: secret})
http.ListenAndServe(addr, h)webhook.Handler shows one run's pending approvals, shows one approval as JSON or as sanitised plain text, and takes a decision bound to the hash it showed.
Every route names the run, and nothing lists runs.
| Route | Does | Success |
|---|---|---|
GET /runs/{run}/approvals |
the run's pending approvals, as JSON, at most Config.Limit (20 by default) |
200 |
GET /runs/{run}/approvals/{id} |
one approval; JSON, or text with Accept: text/plain or ?format=text
|
200 |
POST /runs/{run}/approvals/{id}/approve |
grants it; body {"run_id","approval_id","hash","by","note"}
|
202; the run stays waiting until its owner resumes it |
POST /runs/{run}/approvals/{id}/reject |
refuses it; the same body | 200, with the run cancelled |
POST /runs/{run}/cancel |
ends the run; body {"run_id","by","note"}
|
200 |
A decision is accepted only when its body names the run and approval of the path, the hash the caller was shown, and an identity. Cancel needs the run of the path and an identity, and carries no hash, because it decides no approval.
Errors are JSON, {"error": text, "code": name}.
| Status | Code | When |
|---|---|---|
| 400 | bad_request |
a body that is not a decision, a body that does not name the run and approval of the path, or a missing hash or identity |
| 401 | unauthorized |
a request that does not verify, for any reason below |
| 404 | not_found |
a run or approval that does not exist under the run named, or any other route or method (once the request verifies) |
| 409 | approval_changed |
the stored approval is not the one shown |
| 409 | not_pending |
the approval is already decided |
| 409 | run_not_waiting |
the run is in another state; run_finished for a cancel of a run already finished |
| 409 | approval_hash |
the stored row no longer matches its own hash |
| 410 | expired |
the approval's expiry has passed |
| 413 | body_too_large |
a body over Config.MaxBody
|
| 429 | rate_limited |
Config.Rate exceeded |
An error the runtime returns that the handler does not know is a 500 internal with the message withheld.
Every request, reads included, carries three headers:
| Header | Value |
|---|---|
X-Approval-Timestamp (HeaderTimestamp) |
the sender's clock as Unix seconds |
X-Approval-Nonce (HeaderNonce) |
a value the sender does not reuse, up to 64 characters |
X-Approval-Signature (HeaderSignature) |
v1= followed by the hex HMAC-SHA256 under Config.Secret of the five fields below |
timestamp "\n" nonce "\n" method "\n" path "\n" body
path is the request path as the handler sees it, after any http.StripPrefix, and body is the raw body, empty for a GET. The comparison is constant-time. webhook.Sign computes the value, and webhook.NewRequest(ctx, secret, method, url, body) builds a signed request with the three headers set from the wall clock and a random nonce, which is all a Go client needs; a client of a handler mounted behind a prefix the server strips must sign the stripped path and send the full one itself. The secret never travels in a URL, and it must be at least 32 bytes: webhook.New refuses less, and refuses a config with no Approver.
A timestamp more than Config.Skew from the handler's clock, in either direction, is refused; the default is five minutes. A signature seen once is refused again for as long as its timestamp could be accepted, so a captured request cannot be replayed inside the window. The nonce is what lets a client send the same decision twice in one second and have the second judged on its own. The handler remembers signatures only that long, and the set is bounded by the rate over the window plus the burst; a request that would overflow it is refused as too many signatures in the window. A request with a missing or malformed timestamp, a missing or oversized nonce, a signature that does not match, or a signature already used is a 401.
The order of work is fixed. A request is rate limited first, before its body is read: a token bucket over the whole handler, 10 requests a second with a burst of 20 by default (Config.Rate, Config.Burst), answered 429. Its body is then read through a bound of Config.MaxBody, 16 KiB by default, answered 413. Only then is it verified, and only a verified request is routed. So an unsigned request to a route that does not exist is a 401, not a 404.
A pending approval whose Limits.ApprovalTTL has passed is shown as 410 without writing anything. A decision on it goes to the runtime, whose own expiry path marks the approval expired and cancels the run as approval_expired, and the handler answers 410. A grant's GrantTTL is judged when the consumer resumes, as before.
GET ...?format=text serves webhook.Text(p), an approval in the shape cmd/agentrt prints it: the status and id, the run, the kind, the tool, the policy's reason, the hash, the expiry, who decided and when if it was decided, anything the store could not read, then each argument field and each presentation field on its own lines with its byte size. Every value is escaped with trace.Sanitize, so model-chosen text cannot move a cursor, clear a screen, or forge the look of the prompt, and a string over view.MaxFieldBytes is shown by its head and tail. The last line is one the renderer writes itself, ----- end of approval <id> for tool <name> -----, so the last thing read is not text the model or the policy chose.
Config.Observer, which may be nil, receives two event types that are not persisted (their Seq is 0). webhook.EventDecision (webhook.decision) records every approve, reject, or cancel request that verified, with the status it was answered and the error if refused. webhook.EventRefused (webhook.refused) records a request that did not verify, or exceeded the rate, with the path and the reason. The runtime's own approval.decided event arrives through the Approver's observer when a decision commits.
-
It has no users and no sessions.
byis a label. The shared secret is the only authentication, and whoever holds it can decide any approval of any run whose id they know. Hold it where the operator's chat or web front end runs, not in a browser. - Anyone who can write the database can approve without it. The database, not this handler, is the trust boundary.
- It speaks plain HTTP. Put it behind TLS and a network boundary the operator controls. If a proxy adds a path prefix, strip it before the handler so the signed path is the one it sees.
- It does not resume runs. That needs the consumer's own process.
- It does not list runs or approvals across runs. A caller needs the run's id.
See Security Model for the position this puts the operator in.
go run ./examples/approver # grants, then resumes
go run ./examples/approver -reject # the bot refuses; the run is cancelledexamples/approver is a plain core example with no go.mod of its own. It runs a scripted agent to a publication-shaped approval: a publish_proposal tool of the remote-mutation class and a policy shaped like repo-steward's that always asks, with kind publication and a presentation naming the proposal, its branches, and its files, under a one-hour ApprovalTTL. It then serves the webhook over its database on a loopback port with a random secret, and acts as the chat bot on the other end. The bot holds the secret, the run id, and nothing else: it lists the run's pending approvals, fetches the approval's text rendering and prints it as the message an operator would read, posts the decision bound to the hash it showed, and the example then resumes the run the way the consumer's own process would, which recomputes the hash and executes exactly the recorded request. It ends with a line such as run <id> is COMPLETED (...); publish_proposal ran 1 time(s); with -reject the run ends cancelled and the tool runs zero times. -db names the file; the default is a fresh one under the per-user cache directory, created 0700. It makes no network calls beyond loopback and needs no model.
In a deployment the handler runs where it can open the database (approver.Open) and the consumer's process stays where it is; the bot reaches the handler over a network boundary the operator controls.
Both consumers adopted the channel on 2026-10-03: repo-steward's approve, reject, and cancel commands decide through approver.Open and print the approval with webhook.Text first, and casework's server decides through approver.New with the hash its browser client was shown. Neither mounts the webhook itself. The roadmap's done-when for this item is that repo-steward's publication approval can be granted from the reference implementation and the hash still binds. That was proven against the real repo-steward binary in a scratch end-to-end run on 2026-10-02: a publication approval was granted through the webhook and the hash still bound on resume (TestHandler_GrantThenResumeBindsTheHash covers the same in the package). What remains is adoption in both consumers, which has not happened. The resume stays where it already is, in the consumer's own process, because the channel does not do it.
Approvals and Resume is what is being decided and what happens after. The Operator Command is the terminal channel this one was modelled on. Exporting Events is the read-only counterpart for watching rather than deciding.
Using it
- Getting Started
- Upgrading to v0.5
- Upgrading to v0.4
- Upgrading to v0.3
- Concepts
- Writing an Agent
- The Gate
- Writing Tools
- MCP Tools
- MCP Proxy
- Writing a Policy
- Models and Replay
- Provider Adapters
- Rendering Model Context
- Approvals and Resume
- Interruption and Reconciliation
- Run Lease
- Limits
- Testing a Consumer
- Re-checking a Run
- Measuring Agents
Inside it
- Internals
- Persistence and Inspection
- Performance
- The Operator Command
- Approval Channels
- Exporting Events
- Testing
Deciding on it
- Safety Properties
- Security Model
- Comparison with Frameworks
- Status and Roadmap
- Roadmap
- Design Decisions
- Essays
- Contributing
- Releasing
Reference