Skip to content

Human approvals

Agents taking side-effect actions, such as issuing refunds, sending emails, deleting datasets, or filing tickets, require human oversight. Approvals route decisions to specific humans and record their answers for auditability.

POST /v1/approvals
{
  "request_key": "refund-order-1234",
  "title": "Refund order 1234",
  "summary": "Customer asked for a full refund of $42.00.",
  "payload": { "order_id": "1234", "amount_cents": 4200 },
  "approvers": [{ "email": "dana@acme.example" }],
  "quorum": "QUORUM_ANY_ONE",
  "ttl_seconds": 86400
}

When an approval request is created, Jennah sends notification emails containing secure decision links to designated approvers. Applications can await decisions using the wait endpoint:

POST /v1/approvals/{approval_id}:wait
{ "timeout_seconds": 30 }

Enforcement model

Jennah records whether a human was asked and what they decided. It does not intercept tool calls or automatically enforce decisions. Your application code must check the approval status (e.g., via :wait) before executing actions.

Automatic gating of tool calls belongs in an execution engine that can pause steps. The Approvals API handles the decision requests and resolutions.

Application pattern (the credential needs approval.requests:create to raise the request and approval.requests:read to wait on it):

approval = jennah.approvals.create(
    request_key=f"refund-{order_id}", ...
)

# The SDK loops the bounded server-side wait for you.
outcome = jennah.approvals.wait(approval.approval_id)
if outcome.status != "APPROVAL_STATUS_APPROVED":
    return "refund not authorized"

# Your enforcement point. Nothing above stops this line from running.
issue_refund(order_id)

Creating a request

Idempotency with request_key

Creation requests require a request_key unique to the enterprise and agent. Replaying the same key with identical content returns the original approval without resending notifications; the response sets already_existed: true.

Reusing a key with modified request parameters (title, summary, payload, approvers, quorum, ttl, etc.) returns an ALREADY_EXISTS error.

Duration over absolute timestamps

Requests specify ttl_seconds rather than absolute expiration timestamps so retried requests compute identical parameters and maintain idempotency.

Delivery vs. storage

A successful create (200 OK) indicates the request is durably stored. Notification delivery occurs asynchronously. Approver delivery details (delivery_state and last_delivery_error) can be queried via:

GET /v1/approvals/{approval_id}

This distinguishes pending responses from delivery failures.

Quorum

QUORUM_ANY_ONE resolves on the first approval. QUORUM_ALL needs every listed approver. A rejection is terminal immediately under either: one refusal ends the request however many approvals preceded it.

Reads report required_approvals and approvals_recorded to show partial approval progress.

Other request fields

Field Meaning
agent_id Optional agent the request is about. The caller must have reach over that agent, or the create is PERMISSION_DENIED.
required_approvals Optional. When set it must equal the value the quorum implies (1 for QUORUM_ANY_ONE, the approver count for QUORUM_ALL); a mismatch is INVALID_ARGUMENT. Leave it unset and the server derives it.
record_to_memory When agent_id is set, writes the final outcome into that agent's memory as an execution-log step. Ignored without agent_id. A failed memory write never changes the decision.
notify_run_id, notify_signal_name Reserved. Stored and returned unchanged on reads.

Expiration behavior

Requests that reach their deadline without a decision resolve to REJECTED or EXPIRED, according to on_expire: ON_EXPIRE_DENY (the default) resolves to REJECTED, and ON_EXPIRE_EXPIRE resolves to EXPIRED. Unanswered requests never default to approved.

Expiry takes effect at the deadline whether or not any background process has run, so a read one second after the deadline reports the terminal status and a decision one second after it is refused.

Who may be asked

Members of your enterprise are always addressable.

External approvers (a customer, a partner, an auditor) must appear on your enterprise's approver allowlist by exact address or domain (managed via the Team page, jnh approvals allowlist, or /v1/approvers). Because editing the allowlist requires management permissions, API keys cannot modify it.

Requests referencing unlisted approvers are rejected in full without creating a request or sending emails.

From the terminal:

# Members are not on this list and need no entry. An empty allowlist is correct
# for an enterprise that only ever asks its own people.
jnh approvals allowlist list

# Admit one address. --kind is required and is never inferred from the value.
jnh approvals allowlist add auditor@partner.com --kind email

# Admit a whole domain. This is a much larger grant: it admits addresses that do
# not exist yet.
jnh approvals allowlist add partner.com --kind domain

# Withdraw an entry. This narrows FUTURE addressing only; approvals already
# created keep their approvers and their live links. It asks for
# confirmation; pass --yes in scripts.
jnh approvals allowlist remove <entry-id> --yes

add and remove are refused before any request is sent when you are authenticated with an API key: approval.approvers:manage is management-class and can appear in no key's scope, so widening who the platform will email always has a human's name on it. Reading the list is not restricted that way.

Who may decide

Exactly two origins are accepted:

  • the single-use capability token from the emailed link, or
  • an authenticated human who is a listed approver on that request.

API keys can create requests but cannot decide them, even with a valid token.

Decision authority is capability-based, requiring either a single-use token or an authenticated session from a listed approver, and cannot be granted via IAM roles or administrator permissions.

Optional constraints:

  • require_distinct_approver: Prevents the user who created the request from deciding it. Requests that cannot satisfy this constraint are rejected at creation.
  • require_authenticated_decider: Disables token-based link decisions and requires a signed-in session from an enterprise member listed as an approver. Combining it with external approvers is rejected at creation with INVALID_ARGUMENT, because non-members cannot sign in.

The audit record

Every decision is append-only and carries the deciding approver (their user id for a member, their email and approver id for an external one), the decision, the instant, an optional comment, network origin, user agent, and a cryptographic digest of the rendered request payload to verify what content was presented to the decider.

Decision records are immutable and cannot be updated or deleted.

Security & threat model

Emailed links contain single-use capability tokens. Anyone with access to the recipient's mailbox (including forwarded addresses or shared inboxes) can authorize the request.

  • Mitigations: Tokens are single-use, expire at the request deadline, are stored as hashes, and record the decider's IP address and user agent.
  • Strict authentication: Enable require_authenticated_decider for high-risk operations to require signed-in authentication.

Token exposure prevention

Tokens are passed in the URL fragment (#t=...), which browsers do not transmit in HTTP requests or Referer headers, keeping them out of web server logs and proxy telemetry.

Automated email scanners and link-rewriting proxies prefetch URLs. Viewing an approval page and submitting a decision are separate steps; HTTP GET requests never record approvals.

Data exposure in payloads

summary and payload content is rendered into email notifications. Do not include sensitive credentials, secrets, or unencrypted personal data in request payloads.

Approval content is mailed exactly as written

Jennah does not scan, filter, or redact summary and payload content. What you put in those fields is what the approver receives, including approvers outside your enterprise. This is the platform's behavior, not a temporary gap: there is no redaction on the mail path and none on the memory write path either. Treat every approval notification as unfiltered outbound email, and keep credentials, secrets, and unencrypted personal data out of both fields.

Delivery failures and expiration

If email delivery fails or is delayed beyond the deadline, undecided requests expire as denied (REJECTED or EXPIRED). Check delivery_state or use :resend to handle delivery issues.

Resending notifications

POST /v1/approvals/{approval_id}:resend re-sends notification emails for pending requests. Earlier links remain functional until a decision is recorded.

# Re-arm every approver who has not yet answered.
jnh approvals resend <approval-id>

# Or just one, by approver id or by the address it was sent to.
jnh approvals resend <approval-id> --approver auditor@partner.com

The response returns the updated delivery state for each approver. Resent notifications count against monthly email notification quotas.

Cancelling requests

POST /v1/approvals/{approval_id}:cancel cancels a pending request and invalidates all outstanding decision links. Cancelling an already-resolved request returns a FAILED_PRECONDITION error.

Waiting for decisions

POST /v1/approvals/{approval_id}:wait blocks until the request resolves or the server-side wait timeout is reached. If the wait times out while pending, it returns timed_out: true and the request can be polled again.

Each call waits at most 30 seconds. timeout_seconds is clamped to that cap, and 0 (or omitting it) waits the full 30 seconds. To wait longer, call again.

Listing requests

GET /v1/approvals lists requests, newest first. Optional query parameters narrow the listing:

Parameter Effect
status Only requests in this status, e.g. APPROVAL_STATUS_PENDING
agent_id Only requests naming this agent
only_awaiting_me Only requests waiting on your own decision
page_size, page_token Paging; pass next_page_token from the previous response

Requests about agents outside the caller's reach are never listed. A signed-in caller without approval.requests:read sees only the requests that list them as an approver.

From the CLI

jnh approvals create --request-key refund-order-1234 \
  --title "Refund order 1234" \
  --approver dana@acme.example --quorum any --ttl 24h
jnh approvals get <approval-id>
jnh approvals list --status pending --awaiting-me
jnh approvals wait <approval-id> --timeout 10m
jnh approvals cancel <approval-id>

create also takes --summary, --payload (a JSON object, or @file), --agent, --on-expire deny|expire, --distinct-approver, --require-signin, --record-to-memory, and --wait (with --wait-timeout) to block on the decision in the same command. wait repeats the bounded server-side wait until the request resolves or --timeout (default 10 minutes) elapses. list also takes --agent, --page-size, and --page-token.

Permissions

Permission What it allows
approval.requests:create Raise a request, and resend its notification
approval.requests:read Read any request in the enterprise (GET, :wait), and list them all. Not needed for requests that list you as an approver
approval.requests:cancel Withdraw a pending request
approval.approvers:read See the approver allowlist
approval.approvers:manage Edit it. Management-class, so never in an API key's scope

Approval permissions are not included in the default member role and must be explicitly granted.

Assigned approvers can view, wait on, list, and decide requests addressed to them without any approval permission. The approval.requests:read permission grants access to all approval requests across the enterprise. An API key is never an approver, so a key needs approval.requests:read to read any request, including ones it raised.

Quotas and limits

Enterprise limits govern concurrent pending requests and monthly notification email volume. Addressing approvers outside your enterprise is a tier-level grant.

Every request is also bounded:

Property Limit
Approvers per request 25
Duplicate approver emails Not allowed (compared case-insensitively)
Default ttl_seconds 72 hours
Maximum ttl_seconds 30 days
Server-side wait per :wait call 30 seconds

A create that exceeds the approver or TTL bound, or lists an approver twice, is INVALID_ARGUMENT and rejected in full: nothing is stored and no email is sent. The same applies to a missing request_key, title, or approver. A longer :wait timeout is not refused; it is lowered to the cap.