Agent Quickstart
This guide walks an AI agent from zero to a successful submission. The flow is:
register agent → operator grants scopes → exchange secret for token → validate → self-correct → submitRead the Overview for the envelope and auth model. All examples use the standard envelope.
The Auth Model in One Paragraph
An agent is a registered identity with a secret. An agent cannot do anything until an operator (a human, via the app UI or a session) grants it scopes on a specific form. The agent then exchanges its secret for a 7-day session token and acts within its granted scopes. Grants are read live, so revoking a grant takes effect immediately.
1. Register an Agent
Registering an agent returns the agent's secret once. Store it securely; it cannot be retrieved again.
curl -X POST https://api.kinoforms.com/papi/v1/identity/agents \
-H "x-api-key: $KINOFORMS_API_KEY" \
-H "content-type: application/json" \
-d '{ "name": "intake-bot" }'{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"clientId": "ag_f47ac10b58cc4372a5670e02b2c3d479",
"secret": "ags_live_3b91f2a8c7d4e6b1... (shown ONCE)",
"secretPrefix": "ags_live_3b",
"name": "intake-bot",
"status": "active",
"workspaceId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
},
"apiVersion": "v1"
}If you lose the secret, rotate it (you cannot read it back):
curl -X POST https://api.kinoforms.com/papi/v1/identity/agents/550e8400-e29b-41d4-a716-446655440000/rotate-secret \
-H "x-api-key: $KINOFORMS_API_KEY"Other identity endpoints:
GET /papi/v1/identity/agents— list agents.GET /papi/v1/identity/agents/{id}— fetch one agent.PATCH /papi/v1/identity/agents/{id}— rename or disable ({ "status": "disabled" }; set"status": "active"to re-enable).DELETE /papi/v1/identity/agents/{id}— revoke the agent entirely (terminal; status becomesrevoked, grants and sessions stop authorizing immediately).
2. Operator Grants Scopes
The agent is useless until an operator grants it scopes on a form. A grant binds an agent to one form with a set of scopes. Grant creation is authenticated with a Better Auth session cookie (the app UI's auth) — not an API key, not x-respondent-token.
curl -X POST https://api.kinoforms.com/papi/v1/identity/agents/550e8400-e29b-41d4-a716-446655440000/grants \
-H "cookie: $OPERATOR_SESSION_COOKIE" \
-H "content-type: application/json" \
-d '{
"formId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"scopes": ["agent:read", "agent:validate", "agent:submit"],
"respondentId": "6ba7b812-9dad-11d1-80b4-00c04fd430c8"
}'Grant fields:
formId(required) — the form the grant applies to.scopes(required) — a subset ofagent:read,agent:validate,agent:submit.respondentId(optional) — bind the grant to a specific respondent. If the form requires it, submissions without the assigned respondent are rejected (respondent_not_assigned).
agent:submit requires a workspace admin/editor session
agent:submit grants can only be created by a UI/app session held by a workspace admin or editor on the form's workspace — never by an API key. An API-key caller that tries to grant agent:submit is rejected with submit_not_grantable_by_api_key; binding a respondent via an API key is rejected with respondent_binding_requires_session. agent:read and agent:validate may be granted by an API key. This invariant is enforced in identity.rs:527-553 (grant-creation authorization check).
Manage grants:
GET /papi/v1/identity/agents/{id}/grants— list an agent's grants.DELETE /papi/v1/identity/agents/{id}/grants/{grant_id}— revoke a grant. Takes effect immediately on the next agent call.
3. Exchange the Secret for a Token
The agent trades its secret for a 7-day session token:
curl -X POST https://api.kinoforms.com/papi/v1/auth/token \
-H "content-type: application/json" \
-d '{ "clientSecret": "'"$AGENT_SECRET"'" }'{
"data": { "token": "rt_live_8f2c...", "expiresAt": "2026-06-29T12:00:00Z" },
"apiVersion": "v1"
}Cache the token and reuse it until expiresAt. Send it as x-respondent-token on validate/submit.
4. Validate (and Self-Correct)
Validate is a dry run that always returns 200. Use it to iterate. It needs the agent:validate scope.
curl -X POST https://api.kinoforms.com/papi/v1/validate/$ACCESS_KEY \
-H "x-respondent-token: $SESSION_TOKEN" \
-H "content-type: application/json" \
-d '{ "data": { "email": "not-an-email" } }'{
"data": {
"valid": false,
"errors": [
{ "field": "email", "code": "format", "message": "Value is not a valid email address.",
"help": "https://docs.kinoforms.com/developer/api/validation#format" }
],
"warnings": [],
"missingRequired": ["name"]
},
"apiVersion": "v1"
}The self-correction loop:
- Read
errors— fix each field; follow thehelplink for the exact rule. - Read
missingRequired— supply each missing key. - Re-validate. Repeat until
validistrueandmissingRequiredis empty.
Validate is throttled generously precisely so an agent can iterate fast. See Parsing validation responses for the per-field codes and self-correction recipes.
5. Submit
Once validate is clean, submit for real. This needs the agent:submit scope (operator-granted).
curl -X POST https://api.kinoforms.com/papi/v1/submit/$ACCESS_KEY \
-H "x-respondent-token: $SESSION_TOKEN" \
-H "content-type: application/json" \
-d '{ "data": { "email": "[email protected]", "name": "Ada" } }'A clean submit returns 200 with the created submission. Submit is rate-limited to 1 per 5 seconds per agent (see Rate Limits), so validate first rather than retrying submit in a tight loop.
For long or resumable flows, use partial save-and-continue (?partial=true) — see the Submitter guide.
Signed Requests (RFC 9421)
An agent can self-authenticate by signing its HTTP requests — no bearer token needed. This is gated by the FEATURE_AGENT_SIGNING flag (off by default).
How It Works
The agent signs outgoing requests with an Ed25519 private key following RFC 9421 (HTTP Message Signatures). The covered components are exactly:
@method— the HTTP method (POST, GET, etc.)@authority— the host (e.g.api.kinoforms.com)@path— the request path (e.g./papi/v1/submit/...)
Parameters: created (Unix timestamp), keyid (the registered key identifier), alg="ed25519", nonce (a unique base64url random, 16 bytes).
The resulting Signature and Signature-Input headers are attached to the request. If both a signature AND a bearer token are present, the signature takes precedence. A present-but-invalid signature is a uniform 401 (never a silent fall-through to bearer).
Registering a Key
Generate an Ed25519 keypair. The public key must be in SPKI PEM format (with -----BEGIN/END PUBLIC KEY----- wrappers). Register it:
curl -X POST https://api.kinoforms.com/papi/v1/identity/agents/550e8400-e29b-41d4-a716-446655440000/keys \
-H "cookie: $OPERATOR_SESSION_COOKIE" \
-H "content-type: application/json" \
-d '{
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----"
}'The response includes the derived keyId — "key_" + sha256(publicKeyPem)[:16]. This is the value you put in the keyid signature parameter. The private key NEVER leaves the agent.
Signing a Request
Build the signature base (4 lines, \n-joined, no trailing newline):
"@method": POST
"@authority": api.kinoforms.com
"@path": /papi/v1/submit/abc123
"@signature-params": ("@method" "@authority" "@path");created=1719000000;keyid="key_a1b2c3d4e5f67890";alg="ed25519";nonce="base64url-16-bytes"Sign the base with Ed25519, then set headers:
Signature-Input: sig1=("@method" "@authority" "@path");created=1719000000;keyid="key_a1b2c3d4e5f67890";alg="ed25519";nonce="base64url-16-bytes"
Signature: sig1=:<base64-encoded-signature>:Each nonce must be unique (replays are rejected). The created timestamp must be within ±300 seconds of server time.
Reference Implementation
See experiments/agent-credential-mcp/ — a zero-dependency MCP server that manages credentials and signs requests without exposing the private key to the agent's context window.
Failure Codes Worth Handling
agent_inactive— the agent was disabled or revoked.no_grant— no grant exists for this agent + form.missing_scope— the grant lacks the scope this call needs (e.g.agent:submit).respondent_not_assigned— the form is bound to a respondent the agent is not assigned to.tenant_mismatch/workspace_mismatch/binding_required— the form is outside the agent's binding.submit_not_grantable_by_api_key— an API key tried to grantagent:submit.rate_limited— back off and retry.
All of these are documented in the Validation reference.
Rate Limits
| Tier | Scope | Limit | Env var (default) |
|---|---|---|---|
| Submit | per agent | 1 / 5s (hardcoded) | — |
| Submit | per IP (pre-throttle) | 30 / min | RATE_LIMIT_PAPI_SUBMIT_IP_PER_MINUTE (30) |
| Validate | per agent | 30 / min | RATE_LIMIT_PAPI_VALIDATE_PER_MINUTE (30) |
| Validate | per IP (pre-throttle) | 60 / min | RATE_LIMIT_PAPI_VALIDATE_IP_PER_MINUTE (60) |
| Auth mint | per IP | 30 / min | RATE_LIMIT_PAPI_AUTH_PER_MINUTE (30) |
When a request is throttled the API returns 429 with rate_limited. Retry with exponential backoff (honor any Retry-After).
