Documentation
Agent Access
Duct gives programmatic callers — your own backend automations and partner agents — structured access to your product without raw database access or unrestricted API credentials.
Quick Setup Guides
Two different things get called "agent setup." Pick the one that matches what you're building — full details for each are linked at the end.
A — Independent agent caller (no specific user involved)
A partner or third-party agent calling your public/shared actions — e.g. a support bot answering from your docs, or a stats lookup. Nobody is logging in; the agent just needs scoped, revocable access.
Create an agent access profile
partner-read), scopes, and an optional approval webhook.Share only public identifiers
shell_id and profile slug — never DUCT_SECRET_KEY.Agent exchanges the profile for a token
POST /v1/agent-access-token with { shellId, profile } → returns a scoped at_… token, valid 1 hour.Agent calls your shell
POST /message or /invoke with Authorization: Bearer at_…. Its scopes gate which actions it may trigger.Re-mint after a manifest push
manifest_version; an old token gets 409 MANIFEST_STALE — request a fresh one and retry.Full reference: Agent Access Profiles (scopes, webhooks, staleness).
B — Human-delegated agent caller (acts on behalf of a specific user)
An agent that reads or changes data for one of your users — e.g. "check my last five orders." The agent proves which user it's acting for by carrying a consent token that user explicitly granted.
Configure an identity resolver (recommended)
identityUrl to a thin route on your existing login. Copy the webhook signing secret. Skip this only if consent doesn't need to be bound to a specific known user.Agent requests consent
POST /v1/consent/request with just { shell_id } → returns an approval_url. The agent needs nothing else — no user id, no callback.User approves
approval_url to its human, who opens it and clicks Approve.Duct binds identity (if you configured a resolver)
user_ref, signs it, and redirects back.Your callback exchanges the authorization code
duct_consent_code (90s, single-use). Your server calls POST /v1/consent/redeem with shell credentials to receive the long-lived user_consent_token and user_ref — never put the consent token in the browser URL.Agent receives and reuses the consent token
uct_… on every subsequent call — no need to repeat consent.Verify on your side
X-Duct-User-Ref header, or call POST /v1/verify/consent-token with expected_requesting_shell and expected_presenter_agent_key_id when binding to a specific agent.Full reference: Third-Party Agents And User Identity and Identity resolver.
Consent ≠ per-action approval
Consent (above) authorizes the agent to act for a user at all — it does not clear individual side-effect actions. Those still return202 + approval_url per call regardless of consent; see Agent Confirmation Protocol.Partner handoff
Give partner developers only public identifiers — never DUCT_SECRET_KEY or sk_duct_…. Dashboard → Shell → Agent setup has a copyable handoff block with your shell_id, profile slug, and endpoint skeletons.
Optional: paste the handoff block into your site's llms.txt or partner README so agents that fetch your domain can find public IDs. Do not put secrets in those files.
# Duct partner access — Acme
Token checklist
Partner agents always authenticate with at_. User consent is additive, not a replacement.
# Independent — Bearer onlySee Third-Party Agents And User Identity for consent setup and Agent Confirmation Protocol for per-call side-effect approval.
How Agent Calls Work
Two programmatic caller types
Company backend — cron jobs, internal LLM pipelines, support tools on your servers. Use ashell_token (st_) minted from DUCT_SECRET_KEY. Partner / external agent — integrations that should not receive your root secret. Use an agent_token (at_) from a named profile (shell_id + profile slug). Both hit the same /invoke and /message endpoints.Company Backend And Internal Automations
When the caller runs on infrastructure you control, exchange your shell secret for a short-lived shell token. Pass user_consent_token or X-Duct-User-Consent when acting on behalf of a specific user — your backend can mint duct_delegated after the user has logged in.
import osAgent Access Profiles (Partner And External Agents)
Create named profiles in Dashboard → Shell → Agent setup. Each profile has a unique slug per shell (like partner-read, support-bot, or public-stats), a scope list, and an optional webhook URL. External callers request tokens with only those public identifiers — no sk_duct_….
# Agent requests a 1-hour token (no company secrets required)Company webhook (optional)
When a profile has a webhook_url, Duct POSTs before minting the token. Verify X-Duct-Signature: sha256=… using your profile webhook secret. Respond with { "approved": true } or { "approved": false, "reason": "..." }.
// Duct → your backend
Scope grammar
Scopes are strings on the agent access profile. Prefer narrow tiers:
After a manifest push
Every push increments manifest_version. Agent tokens record the version at exchange time. If you push again before the token expires, invoke may return 409 with MANIFEST_STALE. Re-run POST /v1/agent-access-token and retry with the new token. Shell tokens are not version-pinned the same way. Use agent access profiles when the caller should not hold your root secret; use shell tokens for company-owned backends on your servers.
Natural-Language Agent Query
/message accepts both agent_token (at_…) and shell_token (st_…). Company backends typically use a shell token; partner agents use their scoped agent token. Scope enforcement applies to agent tokens: if the router resolves a side-effect action, the agent token must carry the matching write: scope.
# Company backend (shell token)Human Shell vs Agent API
Deeplink handoff for agents
When a capability exists only as a UI page (no matching agentAccessible action), the router blocks browser navigation for agents but resolves the deeplink URL server-side. The /message response may include:
Without product.baseUrl (or top-level baseUrl), Duct cannot build a navigable handoff link and omits handoff_url.
{Third-Party Agents And User Identity
An at_ token proves which agent is calling. It does not prove which user the agent acts for. User-scoped actions (requiresAuth: true, agentPermissions.requireUserConsent: true) need a separate user delegation token in user_consent_token on /message (or X-Duct-User-Consent on /invoke).
When consent is missing, both /invoke and /message return 403 permission_denied with a structured remediation block (when webhooks.consentCallback is configured) pointing at POST /v1/consent/request. Deny messages do not embed internal action ids in free text.
Human shell ≠ third-party agent
The embed works when a logged-in user mintsduct_delegated through onTokenRequest. External agents cannot call your login endpoint. If your action routes only accept duct_delegated from a human session, partner agents are blocked — even with a valid at_ token and scopes.Anti-pattern
All agentAccessible actions have requiresAuth: true, and your API middleware only verifies duct_delegated minted after POST /api/auth/login. Result: human shell chat works; Postman or partner agents get 403 permission_denied from Duct or 401 from your API.
Correct patterns
# Third-party agent — user-scoped read (after user approved consent)user_ref — your opaque user handle
The agent sends only its shell_id — it never sends user_ref, a callback URL, or action names. The user's identity is bound at approval time by your identity resolver (manifest.webhooks.identityUrl), a thin route on your existing login that vouches for an opaque user_ref signed with your shell webhook secret. Duct then issues a uct_ covering everything the agent profile is permitted to call. Side-effect actions still require per-call human approval.
Choose a Duct-facing alias for user_ref
Duct does not care which database or auth provider you use. Issue a dedicated opaque identifier per user for agent flows (e.g.duct_sub_…, a minted UUID, or a one-way hash) — not necessarily your storage primary key. Map it to the live user only on your server. Never put PII in user_ref.Identity resolver
Set manifest.webhooks.identityUrl in Dashboard → Settings → Webhooks. Duct redirects the user to this route (your existing login/session) at approval time. You confirm who they are, return an opaque user_ref plus sig = HMAC_SHA256(whsec, "<duct_state>.<user_ref>"), and Duct mints the uct_. See the full identity resolver guide for pseudocode and the consent callback contract.
See also the AI integration prompt — especially the third-party agents and user identity section — for middleware checklists when auditing a company codebase.
Consent callback — what to do on your end
After the user approves, Duct redirects to your callback_url with a short-lived duct_consent_code query parameter (90 seconds). Your handler exchanges it server-to-server and stores the grant for the agent:
// Consent callback — exchange duct_consent_code for uct_On proxied action calls, read X-Duct-User-Ref and map your opaque alias to the live user with your own lookup layer.
Cross-Shell Discovery
Any public Duct shell can call another public shell — no pre-configuration required on either side. The target shell controls exposure via registry_visibility, intershell_enabled, and per-action agentAccessible. Duct routes and enforces consent.
# Search for a capability across the Duct networkTo make your shell callable cross-shell, set in your duct.config.ts:
export default defineDuctConfig({
allowedCallers is optional
If you setallowedCallers, only those listed shells can call you. Omitting it means any public Duct shell can call your agentAccessible actions. To close cross-shell access entirely, set intershell_enabled: false.Agent Confirmation Protocol (human in the loop)
Side-effect actions always require a human to approve before execution. When an agent calls a side-effect action, Duct returns 202 confirmation_required with an approval_url — a human-readable page where the authorised user reviews the action and clicks Approve. The page then shows an execution_token the human copies and hands back to the agent. The agent retries the exact same call with X-Duct-Execution-Token and receives a 200.
# Step 1 — agent requests a side-effect action (with user_consent_token)On execution-token retry (X-Duct-Execution-Token), Duct re-verifies user_consent_token before consuming the execution token. If consent was revoked after approval, the retry returns 403 consent_invalid without burning the token.
Mode 1 — poll for approval (autonomous agents)
The agent never receives a token from a human. It polls GET /v1/confirmations/:confirmation_id/status (rate-limited). While status is pending, keep polling. When approved, the JSON body includes execution_token and execution_token_expires_in — retry your original invoke with X-Duct-Execution-Token.
The token is returned only when the poll carries the agent credential that created the confirmation; an unauthenticated poll still works but returns status alone. Reading a decision grants nothing — only a human can approve, so an agent polling its own confirmation can never approve it.
Mode 2 — confirmation webhook
Pass confirmation_webhook_url on POST /invoke or POST /message. When a human approves, Duct POSTs an HMAC-signed confirmation.approved event to that URL with execution_token, confirmation_id, action, and params. Verify X-Duct-Signature the same way as dashboard webhooks.
Orchestration contract (/message vs /invoke)
POST /message is one orchestration turn: classify → route → bounded execution. It auto-expands manifest dependsOn chains and honors paramMapping between steps — the same behavior as the human shell. POST /invoke runs a single action when you already know action + params.
On upstream_error or partial steps[], your agent owns the outer loop: read outputs, fix params, and call /invoke or another /message (optionally with history or session_id).
Agent safety model
Agents should not get blanket product access. KeepagentAccessible narrow, require consent for user-scoped actions, and leave mutations disabled unless the workflow is intentionally automated. The approval URL flow ensures a human is always in the loop for irreversible actions.Analytics and audit visibility
Human chat and agent API traffic show up differently in the dashboard:
Post-confirmation retry on /message
After a human approves viaapproval_url, retry the same POST /message with X-Duct-Execution-Token. The platform skips the NL router, executes the bound action, and Audit shows confirm → approve → execute on one chain.Audit glossary
Terms you will see in Dashboard → Audit turn traces, filters, and Advanced invoke rows. Tap the ? icons in the audit UI for the same short definitions with a link back here.
Outcome Receipts — What They Prove
A receipt is a signed statement that the gate made a decision — it is not proof that a side effect actually completed, and it is not a guarantee about business outcomes. Every receipt carries an explicit scope_of_claim with two lists: what it asserts and what it does_not_assert. Read that field before trusting anything else on the receipt.
Verifying a receipt
Send the receipt to POST /v1/receipts/verify. The response reports valid plus, on failure, a specific reason: WRONG_CLAIM (presented as the wrong claim type), REPLAYED (already honored once), DELEGATION_EXPIRED / DELEGATION_REVOKED (the delegation it references is no longer active), or MANIFEST_STALE (the shell's manifest has changed since the receipt was issued).
When a shell has offline verification keys configured, receipts are signed with Ed25519 and can be verified without calling Duct at all — fetch the public key set from GET /v1/keys/receipts. Receipts without that configuration fall back to a signature Duct itself can check on request.
To prove an execution matches its declared intent (not a hijacked or mutated one), pass the paired duct:intent:v1 receipt as intent_receipt in the same request. Duct verifies both signatures independently and confirms the executed action, params, and order line up before returning intent_matched: true.
Credential recheck timing
By default Duct re-verifies a uct_/at_ consent token on every invoke (on-process). For high-volume, low-risk read actions this round trip is often unnecessary — set an action's or a shell's credentialCheckPolicy to on-accept to verify once and trust a cached acceptance for up to 24h, or both to require a fresh acceptance and still live-check every call for sensitive actions. Leaving it unset keeps today's behavior exactly as-is.