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.

1

Create an agent access profile

Dashboard → Shell → Agent setup → New profile. Pick a slug (e.g. partner-read), scopes, and an optional approval webhook.
2

Share only public identifiers

Give the agent developer the shell_id and profile slug — never DUCT_SECRET_KEY.
3

Agent exchanges the profile for a token

POST /v1/agent-access-token with { shellId, profile } → returns a scoped at_… token, valid 1 hour.
4

Agent calls your shell

POST /message or /invoke with Authorization: Bearer at_…. Its scopes gate which actions it may trigger.
5

Re-mint after a manifest push

A push bumps 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.

1

Configure an identity resolver (recommended)

Dashboard → Settings → Webhooks → set 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.
2

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.
3

User approves

The agent hands approval_url to its human, who opens it and clicks Approve.
4

Duct binds identity (if you configured a resolver)

Duct redirects to your identity resolver; your app confirms who the user is, picks an opaque user_ref, signs it, and redirects back.
5

Your callback exchanges the authorization code

Duct redirects to your consent callback with 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.
6

Agent receives and reuses the consent token

Store the redeemed token server-side and deliver it to the agent out-of-band. The agent sends the same uct_… on every subsequent call — no need to repeat consent.
7

Verify on your side

Check the forwarded 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 return 202 + 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.

shell_id
Public shell identifier (e.g. shell_acme_prod).
agent_profile
Profile slug from Agent setup (e.g. partner-read).
Token endpoint
POST /v1/agent-access-token with { shellId, profile } → at_…
Message endpoint
POST /v1/shells/:shellId/message with Authorization: Bearer at_…

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.

markdown
# Duct partner access — Acme

Token checklist

Partner agents always authenticate with at_. User consent is additive, not a replacement.

Authorization: Bearer at_…
Required on every partner call. Mint from shell_id + profile at POST /v1/agent-access-token.
user_consent_token (uct_…)
Add only for human-delegated / user-scoped actions (requiresAuth, requireUserConsent). Omit for independent public reads.
duct_delegated
Human shell embed path (onTokenRequest) — not the default partner path.
Side effects
uct_ authorizes the request; Duct still returns 202 + approval_url per mutating call.
bash
# Independent — Bearer only

See Third-Party Agents And User Identity for consent setup and Agent Confirmation Protocol for per-call side-effect approval.

How Agent Calls Work

Authenticate
Company backends use POST /v1/shell-token (st_ from DUCT_SECRET_KEY). Partner agents use POST /v1/agent-access-token (at_ from shell_id + profile slug — no root secret).
Route
The caller sends natural language to /message (NL routing) or calls a known action directly through /invoke.
Authorize
Duct checks manifest policy, caller type, side effects, consent, action accessibility, and profile scopes for every request.
Execute
Allowed calls are proxied to the product API and returned as structured JSON.

Two programmatic caller types

Company backend — cron jobs, internal LLM pipelines, support tools on your servers. Use a shell_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.

python
import os

Agent 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_….

bash
# 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": "..." }.

json
// Duct → your backend

Scope grammar

Scopes are strings on the agent access profile. Prefer narrow tiers:

read:*
Any non-mutating GET action marked agentAccessible.
read:<actionId>
One specific read action.
write:<actionId>
One mutating action (POST/PUT/PATCH or sideEffects: true).
delete:<actionId>
Any DELETE for that action.
delete:<actionId>:<recordId>
DELETE only when invoke params include that record id (e.g. params.id).

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.

bash
# Company backend (shell token)

Human Shell vs Agent API

Human shell
Can return text, show generated UI cards, ask for confirmation, and hand the user to a page with a deeplink.
Agent API
Returns text or structured JSON — no in-chat UI cards. When the router would deeplink a human, `/message` returns handoff_url, deeplink_id, and handoff_message so the agent can pass the URL to its user.
Side effects
Human users confirm in the shell. Agents need explicit manifest permission and user consent where required.
Sessions
Human sessions preserve conversation context. Agent callers should send relevant context in each request or maintain their own state.

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:

handoff_url
Fully resolved absolute product URL (requires product.baseUrl). Relative paths are never returned to agents.
deeplink_id
Manifest deeplink id that triggered the handoff.
handoff_message
Short prose that includes the absolute URL for the agent to share with its user.

Without product.baseUrl (or top-level baseUrl), Duct cannot build a navigable handoff link and omits handoff_url.

json
{

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.

at_ (agent token)
Issued by Duct from shell_id + agent profile. Scopes which actions the agent may request. Not a user login.
uct_ (Duct consent)
Issued after user approves POST /v1/consent/request. Up to 24h, revocable. Preferred for third-party agents on user-scoped reads.
duct_delegated
Issued by your backend (generateDuctToken) after the user logs into your app. Used by the human shell embed via onTokenRequest.

Human shell ≠ third-party agent

The embed works when a logged-in user mints duct_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

Public reads
requiresAuth: false on the action; at_ + read: scope is enough. Use only when the handler needs no user session.
User-scoped reads (partners)
User approves Duct consent once → agent passes uct_ as user_consent_token. Your API verifies uct_ via POST /v1/verify/consent-token.
Internal automation (company backend)
Shell token (st_) + duct_delegated as user_consent_token when your server mints delegation for a logged-in user. Same paths as human shell.
Human shell
onTokenRequest → duct_delegated → the shell forwards as X-Duct-User-Consent. No change needed for embed.
Side effects (delete, etc.)
uct_ or duct_delegated authorizes the request; Duct still returns 202 + approval_url per call. Your API may enforce extra roles (e.g. admin).
bash
# 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.

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:

typescript
// 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.

bash
# Search for a capability across the Duct network

To make your shell callable cross-shell, set in your duct.config.ts:

ts
export default defineDuctConfig({

allowedCallers is optional

If you set allowedCallers, 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.

bash
# Step 1 — agent requests a side-effect action (with user_consent_token)
approval_url
Opaque link to the confirmation — never contains a nonce. Default DUCT_APPROVAL_MODE=operator_session: a signed-in dashboard operator approves. Set DUCT_APPROVAL_MODE=link_holder to let independent agents forward the link to a human browser (channel possession only — optional CAPTCHA). The agent itself can never approve on its own.
execution_token
Single-use, 15-minute TTL. Bound to the exact action + parameters + manifest version that was confirmed. Cannot be replayed for a different call.
expires_in
86400 seconds (24 hours) by default for the confirmation window — long enough for an approver in another timezone to act. Tunable per deployment.
user_consent_token
Still required — authorises the agent to request the action. Approval is a separate per-call gate on top.

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. Keep agentAccessible 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 /message
One orchestration turn: classify → route → bounded execution. Counts toward Analytics message KPIs when a session is attached; the full action and confirmation lifecycle appears in Audit under Agent actions when no session is attached.
POST /invoke
Deterministic single action when the caller already knows action + params. Appears in Audit; use /message if you need natural-language routing and Analytics message counts.
Analytics → Recent sessions
Grouped by session in the last 24 hours. Expands a merged timeline: user message, router decision (with cache hints), delivery, and invoke rows. Open audit links each session or event to Audit → Conversations or a highlighted execution.
Audit → Conversations
Chat sessions grouped by session ID — embedded shell (Human, h_… sessions), dashboard playground Human + Agent tabs (Playground, g_… sessions), and stateful agent /message traffic. Each card shows a caller badge. Turns include routing trace where available, action chains, and turn/correlation IDs. Search by turn ID, correlation ID, or message text. Stateless /invoke without a session appears under Agent actions.
Audit → Agent actions
Stateless Agent API /invoke traffic. Shows confirmation → human approve → retry with X-Duct-Execution-Token → success as one timeline.
Audit → Advanced
Collapsed panel for invoke rows and delivery counters, tamper-evident hash-chain verification, and CSV/JSON export. Delivery rows (event type message) count completed replies; routing-only invoke rows record NL /message outcomes without an execution ID.
Caller badges
Human — embedded shell chat on your product (h_… sessions). Playground — dashboard playground Human or Agent tab (g_… sessions; isolated from your embed on the same browser). Agent — external at_ token. Network — cross-shell (from_shell set). Shell — company shell token. Filter by caller in Audit.

Post-confirmation retry on /message

After a human approves via approval_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.

Turn ID
One chat round-trip: user message, routing, tool calls, and reply. Trace steps, invoke rows, and LLM usage for that message share this ID.
Correlation ID
Request-scoped ID for a single shell chat HTTP request. Propagates across Duct services so support can follow one request end to end.
Session ID
Groups multiple turns under Audit → Conversations. h_… = embedded human shell on your product; g_… = dashboard playground (Human or Agent tab); a_… = agent caller session.
Agent access profile
The reusable identity an external agent authenticates as (aap_…). Defines scopes and which shells/actions it may call. Several access tokens can be minted from one profile, so it is the coarser grouping in Audit → Agent actions.
Agent access token
One short-lived at_ token minted from an agent access profile (its jti). Stateless agent calls made with the same token group under it in Audit → Agent actions — the agent analog of a Session ID for human chats.
Execution ID
One action invoke through the Duct gate — permission check, optional confirmation, proxy to your API, outcome. Several raw invoke rows can belong to the same execution.
Manifest hash
Fingerprint of the manifest version in effect when the action ran. Shows which action definitions and permissions Duct evaluated.
Manifest cache
Router decision badge: the router served the shell manifest from cache instead of refetching it. Hits are normal and reduce latency.
Context cache
Router decision badge: cached shell context (widget settings, session hints) was reused before routing.
Route memo
Router decision badge: a recent routing decision for a similar message was reused, skipping a full LLM routing call. “Route memo miss” means no memo applied and routing ran fresh.
Delivery record
Advanced row (event type message): marks one completed reply for Analytics and the hash chain. Not an action invoke — execution and permission fields live on sibling invoke rows or in Conversations turn trace.
Verify audit chain
Shell-wide tamper check over hash-chained interaction_events rows. In Dashboard → Audit, use Verify audit chain (page header) to run continuity verification across the whole shell. Per-conversation Verify session checks only rows for that session_id — useful for a slice, but deletions elsewhere in the chain require the shell-wide check.
Playground session
Dashboard playground traffic (Human and Agent tabs) uses isolated browser storage and g_… server sessions so it does not share chat history with your embedded shell on the same machine. Appears in Audit → Conversations with a Playground caller badge. Agent tab uses guest tokens; Human tab auto-mints a playground guest token for quick tests.

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.

duct:action:v1
The gate admitted or denied this action under the stated authority at issued_at. Does not assert the upstream call succeeded or that its result was correct.
duct:intent:v1
The caller declared this intent (e.g. requested a confirmation) at issued_at. Does not assert an execution ever happened — a paired duct:action:v1 receipt with a matching intent_ref is the proof of execution.
duct:authority:v1
An authority or scope check ran and returned the stated verdict. Does not assert the scope itself was configured correctly.
capture_mode
gateway_observed (Duct itself made the decision) or self_attested (a caller reported it). self_attested receipts are never promoted to gateway_observed — verifiers should weight them accordingly.

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.