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:
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 provides the underlying decision primitive.
Application pattern:
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 201 Created status indicates the request is durably stored. Notification delivery occurs asynchronously.
Approver delivery details (delivery_state and last_delivery_error) can be queried via:
This allows applications to distinguish between pending responses and 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 surface partial approval progress.
Expiration behavior
Requests that reach their deadline without a decision resolve to REJECTED (default) or EXPIRED, according to on_expire. 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.
jnh approvals allowlist remove <entry-id>
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 (four-eyes principle). 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.
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
Email link authentication
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_deciderfor 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.
Link prefetching safety
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.
Permissions
| Permission | What it allows |
|---|---|
approval.requests:create |
Raise a request, and resend its notification |
approval.requests:read |
Read any request in the enterprise, and list them all |
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 and decide requests addressed to them without administrative permissions. The approval.requests:read permission allows viewing all approval requests across the enterprise.
Quotas and limits
Enterprise limits govern concurrent pending requests and monthly notification email volume. Addressing approvers outside your enterprise is a tier-level grant.