Define the policy.
Bound the payment.
Know your agent, set spending rules, and grant bounded authority. Connect each signed payment request to its identity, delegation, and verifiable decision trail.
The flow: define policy → bind a signing identity → delegate → authorize → approve when required → execute → reconcile.
https://app.trellisfinance.ioUse app.trellisfinance.io for API requests. www.trellisfinance.io hosts these developer docs.
KYA: Know Your Agent
Know who’s acting, on whose behalf, and under what authority. Trellis connects a signing identity to an organization and its delegated permissions. KYA complements policy: a policy defines what is allowed; identity and delegation establish who may exercise it.
| Question | Current mechanism | What it establishes |
|---|---|---|
| Which identity sent this? | Registered Ed25519 key and signed intent | Possession of that key and integrity of the exact request bytes. |
| On whose behalf? | Organization-scoped agent record and owner-issued delegation | The organization record under which permission was granted; not independently verified legal identity. |
| With what authority? | Policy, delegated budget, expiry, and any required human approval | The boundaries checked before an action proceeds. |
| Why was it permitted? | Stored intent and digest, decision snapshots, signed audit events | An attributable history of the evaluated request and later state changes. |
A signing identity may represent a workflow, not every ephemeral agent process. Separate keys give distinct attribution and delegation boundaries. Sharing a key shares that identity; a signature cannot distinguish the processes holding it.
Define the policy → · Register the signing identity → · Understand signed intent and evidence →
Integration quickstart
Connect a registered agent to bounded authority, submit its signed intent, and inspect the decision and evidence. The request below uses procurement as one example workflow.
1. Connect your workspace
Sign in to your Trellis workspace with your provisioned account. If you need access, contact us. No server installation is required.
export TRELLIS_URL=https://app.trellisfinance.io2. Grant a bounded authority
In Agents & authority, register an agent and retain its downloaded PRIVATE.json file. Create a USD policy allowing northstar, with a $1,000 per-purchase limit and a $500 approval threshold. Issue a delegation with a $2,000 budget and a future expiry. Record the delegation ID and expiry shown in the workspace.
In Developer API, create a key with authorize and execute scopes. The token is returned once in the secret download. Load that token into TRELLIS_API_KEY in your local shell; keep it out of source control and public browser code.
3. Create and sign the purchase
Save the sign.mjs helper below. In a private working directory, replace the three setup values and run:
export KEY_FILE='/path/to/trellis-agt_PRIVATE.json'
export DELEGATION_ID='dlg_REPLACE_WITH_YOUR_ID'
# Set to the delegation's actual expires_at value:
export DELEGATION_EXPIRES_AT='REPLACE_WITH_UNIX_SECONDS'
umask 077
node --input-type=module <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
const key = JSON.parse(readFileSync(process.env.KEY_FILE, 'utf8'));
const expiry = Math.min(Math.floor(Date.now()/1000)+600,
Number(process.env.DELEGATION_EXPIRES_AT));
if (!Number.isFinite(expiry) || expiry <= Date.now()/1000) {
throw new Error('Set a future delegation expiry');
}
const intent = {
org_id: key.org_id, agent_id: key.agent_id,
delegation_id: process.env.DELEGATION_ID,
action: 'purchase.create', audience: 'trellis-procurement-v1',
nonce: randomUUID(), expires_at: expiry,
supplier_id: 'northstar', item_id: 'keyboard',
quantity: 4, amount_minor: 26000, currency: 'USD'
};
writeFileSync('purchase.json', JSON.stringify(intent), { flag: 'wx', mode: 0o600 });
JS
node sign.mjs "$KEY_FILE" purchase.json authorization-envelope.json4. Evaluate the purchase
curl -sS -D authorization-headers.txt \
"$TRELLIS_URL/v1/authorizations" \
-H "Authorization: Bearer $TRELLIS_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @authorization-envelope.json \
-o authorization.json
cat authorization.jsonExpect HTTP 201 and status: authorized with the setup above. A policy denial also returns 201; always inspect status and reason before continuing. The response includes the exact intent, its digest, and the policy/delegation snapshots.
5. Execute and inspect
node --input-type=module <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
const a = JSON.parse(readFileSync('authorization.json', 'utf8'));
// This example executes an automatically authorized purchase.
if (a.status !== 'authorized') throw new Error('Inspect the decision before execution');
const intent = {
...a.intent, action: 'purchase.execute', nonce: randomUUID(),
authorization_id: a.id, intent_digest: a.intent_digest,
expires_at: Math.min(a.intent.expires_at, Math.floor(Date.now()/1000)+600)
};
writeFileSync('execution.json', JSON.stringify(intent), { flag: 'wx', mode: 0o600 });
JS
node sign.mjs "$KEY_FILE" execution.json execution-envelope.json
curl -sS -i "$TRELLIS_URL/v1/executions" \
-H "Authorization: Bearer $TRELLIS_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @execution-envelope.jsonExpect HTTP 202 with status: pending. Refresh Requests & approvals in the workspace to see the worker’s outcome. This UI uses a human session; developer keys cannot poll workspace state.
Try four monitors next ($720): the default $500 threshold requires human approval. Use the app to approve the exact digest before creating its execution request.
Two credentials. Two responsibilities.
A developer key identifies and meters an integration. An agent signature proves possession of the registered signing key. A delegation decides what that agent may do.
| Caller | Credential | Available actions |
|---|---|---|
| Machine integration | Bearer developer key + signed envelope | Authorize and/or execute, according to key scope. |
| Human owner | HttpOnly session cookie | Manage agents, policies, delegations, developer keys, and approvals. |
| Human approver | HttpOnly session cookie | Approve/reject purchases and perform authorized reconciliation operations. |
| Human viewer | HttpOnly session cookie | Read organization state and evidence. |
Authorization: Bearer YOUR_API_KEY
Content-Type: application/jsonHuman POST requests must include the exact configured application Origin. For the hosted workspace, use Origin: https://app.trellisfinance.io. Login uses POST /auth/login with email/password JSON; logout uses POST /auth/logout. The browser app handles the session flow.
The browser can submit signed purchases using its same-organization session instead of a bearer key. A supplied invalid bearer key never falls back to a session. A developer key cannot approve an agent’s purchase or grant new authority.
Sign the bytes you send
Every machine purchase and execution request uses an Ed25519 envelope. Serialize the intent once, prepend the UTF-8 string trellis-intent-v1 and one newline byte, then sign those bytes. Encode the original payload and signature separately using unpadded base64url.
{
"payload": "BASE64URL_OF_EXACT_INTENT_BYTES",
"signature": "BASE64URL_OF_ED25519_SIGNATURE"
}This helper reads the PKCS#8 key downloaded from your workspace. It runs on your machine; the private key never goes to Trellis. The \n in the JavaScript string below evaluates to one newline byte.
// sign.mjs — Node.js 20+, no dependencies
import { readFileSync, writeFileSync } from 'node:fs';
import { createPrivateKey, sign } from 'node:crypto';
const [keyPath, intentPath, outputPath] = process.argv.slice(2);
if (!keyPath || !intentPath || !outputPath) {
throw new Error('Usage: node sign.mjs KEY.json INTENT.json ENVELOPE.json');
}
const keyFile = JSON.parse(readFileSync(keyPath, 'utf8'));
if (keyFile.format !== 'trellis-agent-key-v1') throw new Error('Wrong key format');
const payload = readFileSync(intentPath);
const intent = JSON.parse(payload.toString('utf8'));
if (intent.org_id !== keyFile.org_id || intent.agent_id !== keyFile.agent_id) {
throw new Error('Intent does not match the key owner');
}
const key = createPrivateKey({
key: Buffer.from(keyFile.private_key, 'base64url'),
format: 'der', type: 'pkcs8'
});
if (key.asymmetricKeyType !== 'ed25519') throw new Error('Expected Ed25519');
const bytes = Buffer.concat([Buffer.from('trellis-intent-v1\n'), payload]);
const envelope = {
payload: payload.toString('base64url'),
signature: sign(null, bytes, key).toString('base64url')
};
writeFileSync(outputPath, JSON.stringify(envelope), { mode: 0o600, flag: 'wx' });| Intent field | Rule |
|---|---|
| org_id / agent_id / delegation_id | Use the organization and registered agent bound to an active delegation. |
| action | purchase.create for authorization; purchase.execute for execution. |
| audience | Exactly trellis-procurement-v1. |
| nonce | 16–128 characters. Unique per organization + agent, across both actions. UUIDs work. |
| expires_at | Integer Unix seconds, in the future, no more than 15 minutes ahead, and no later than delegation expiry. |
| amount_minor / currency | Integer USD cents. 26000 means $260.00. |
| supplier_id / item_id / quantity | Must match the supported item definition and its price. |
Choose your language
Use JavaScript (Node.js), Go, or Python to sign the same request envelope. Each example loads the downloaded PKCS#8 key, checks the organization and signing identity, preserves the exact intent bytes, and refuses to overwrite a saved envelope. These are small, runnable examples rather than SDKs.
purchase.json, then choose one signer below. Use the same language to sign execution.json after authorization and any required human approval. All three use the same HTTP endpoints and curl steps.JavaScript
node sign.mjs PRIVATE.json purchase.json authorization-envelope.json
node sign.mjs PRIVATE.json execution.json execution-envelope.jsonGo
Save this complete helper as sign.go.
Show sign.go
// Go 1.22+, standard library only. Run outside the backend module or build this file.
package main
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"os"
)
func run() error {
if len(os.Args) != 4 {
return fmt.Errorf("usage: go run sign.go KEY.json INTENT.json ENVELOPE.json")
}
keyBytes, err := os.ReadFile(os.Args[1])
if err != nil {
return err
}
var keyFile struct {
Format string `json:"format"`
OrgID string `json:"org_id"`
AgentID string `json:"agent_id"`
PrivateKey string `json:"private_key"`
}
if err = json.Unmarshal(keyBytes, &keyFile); err != nil {
return err
}
if keyFile.Format != "trellis-agent-key-v1" {
return fmt.Errorf("wrong key format")
}
payload, err := os.ReadFile(os.Args[2])
if err != nil {
return err
}
var intent struct {
OrgID string `json:"org_id"`
AgentID string `json:"agent_id"`
}
if err = json.Unmarshal(payload, &intent); err != nil {
return err
}
if intent.OrgID != keyFile.OrgID || intent.AgentID != keyFile.AgentID {
return fmt.Errorf("intent does not match the key owner")
}
der, err := base64.RawURLEncoding.DecodeString(keyFile.PrivateKey)
if err != nil {
return err
}
parsed, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return err
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok {
return fmt.Errorf("expected Ed25519")
}
message := append([]byte("trellis-intent-v1\n"), payload...)
envelope := struct {
Payload string `json:"payload"`
Signature string `json:"signature"`
}{
base64.RawURLEncoding.EncodeToString(payload),
base64.RawURLEncoding.EncodeToString(ed25519.Sign(key, message)),
}
file, err := os.OpenFile(os.Args[3], os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
err = json.NewEncoder(file).Encode(envelope)
closeErr := file.Close()
if err != nil {
return err
}
return closeErr
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}go run sign.go PRIVATE.json purchase.json authorization-envelope.json
go run sign.go PRIVATE.json execution.json execution-envelope.jsonPython
Save this complete helper as sign.py.
Show sign.py
"""Python 3.10+; install cryptography in a virtual environment."""
import base64
import json
import os
import sys
from pathlib import Path
from cryptography.hazmat.primitives.serialization import load_der_private_key
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def b64(raw):
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def main():
if len(sys.argv) != 4:
raise SystemExit("Usage: python sign.py KEY.json INTENT.json ENVELOPE.json")
key_path, intent_path, output_path = sys.argv[1:]
key_file = json.loads(Path(key_path).read_text())
if key_file.get("format") != "trellis-agent-key-v1":
raise ValueError("Wrong key format")
payload = Path(intent_path).read_bytes()
intent = json.loads(payload)
if (intent.get("org_id"), intent.get("agent_id")) != (key_file["org_id"], key_file["agent_id"]):
raise ValueError("Intent does not match the key owner")
encoded = key_file["private_key"]
der = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
key = load_der_private_key(der, password=None)
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("Expected Ed25519")
envelope = {"payload": b64(payload), "signature": b64(key.sign(b"trellis-intent-v1\n" + payload))}
# Exclusive creation prevents overwriting an envelope needed for safe retries.
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as output:
json.dump(envelope, output)
if __name__ == "__main__":
main()python3 -m venv .venv
source .venv/bin/activate
python -m pip install cryptography
python sign.py PRIVATE.json purchase.json authorization-envelope.json
python sign.py PRIVATE.json execution.json execution-envelope.jsonRun the execution command only after creating its intent in quickstart step 5. A human approval uses the app; a developer key cannot grant itself approval. Keep the saved envelopes for retries rather than creating a new nonce after an ambiguous network response.
Shared HTTP request
curl -sS -D authorization-headers.txt \
"$TRELLIS_URL/v1/authorizations" \
-H "Authorization: Bearer $TRELLIS_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @authorization-envelope.json \
-o authorization.jsonA 201 response can still contain denied or awaiting_approval. Inspect the decision before executing. See errors and retries for 429 responses and lost responses.
Register a signing identity
The API calls this an agent. It represents a public key authorized to act under a delegation, not an agent runtime hosted or managed by Trellis. A workflow can use one identity; use separate keys when you need independent attribution and permissions.
Access: owner session. Register a 32-byte raw Ed25519 public key, encoded as unpadded base64url. Keep the corresponding private key in your own runtime.
{
"name": "Procurement assistant",
"public_key": "YOUR_RAW_PUBLIC_KEY_BASE64URL"
}201 response: an Agent object with id, name, public_key, owner_id, and status: active. Names are 1–120 characters. The browser app can generate and register the key for evaluation.
Policies & delegations
A policy defines the rules. A delegation binds those rules to one agent, a lifetime budget, and an expiry. Policy records are immutable through the API; create a new policy and delegation to change the rules.
Access: owner session. Amounts below are cents, not dollars.
{
"name": "Office supplies",
"currency": "USD",
"suppliers": [
"northstar"
],
"per_action_minor": 100000,
"approval_above_minor": 50000
}| Field | Validation |
|---|---|
| currency | USD only. |
| suppliers | 1–20 entries; supported example counterparties are northstar and workroom. |
| per_action_minor | 1–100,000,000 cents. |
| approval_above_minor | 0 through the per-purchase limit. A purchase strictly above this value requires a human decision. |
201 response: the policy, with a server-generated pol_… ID and version: 1.
{
"agent_id": "agt_YOUR_AGENT",
"policy_id": "pol_YOUR_POLICY",
"budget_minor": 200000,
"expires_at": 1799999999
}Replace the illustrative expiry with a future Unix timestamp within 30 days. Budget must be 1–100,000,000 cents. Returns 201 with a dlg_… ID and status: active. Each delegation grants its own independent budget; reservations and completed purchases count toward its exposure.
Owner session; send {}. Returns the revoked delegation. Revocation cancels queued or unexecuted actions tied to it. It cannot reverse an already completed payment.
Evaluate a purchase
Access: developer key with authorize scope plus signed envelope. The decoded payload uses action: purchase.create. Validation checks signature, active delegation, expiry, supplier, catalog price, per-action limit, and remaining budget.
{
"id": "auth_EXAMPLE",
"intent_digest": "SHA256_OF_EXACT_PAYLOAD",
"status": "authorized",
"reason": "within_delegated_authority"
}| HTTP 201 decision | Meaning | Next step |
|---|---|---|
| authorized | The purchase fits its delegated authority. Budget is reserved. | Submit a separate signed execution. |
| awaiting_approval | The purchase exceeds the human-approval threshold. | An owner or approver must approve this exact digest. |
| denied | Policy or budget evaluation failed. | Inspect reason; do not execute. |
Common denial reasons: supplier_not_allowed, quote_mismatch, per_action_limit, budget_exceeded, and currency_not_allowed. A completed evaluation is not proof that an order executed.
Approve the exact purchase
Access: owner or approver session, with the configured Origin header. Use the authorization ID in the URL. The digest binds approval to the evaluated purchase.
{
"decision": "approve",
"intent_digest": "DIGEST_FROM_AUTHORIZATION"
}Use reject to reject the purchase and release its reservation. The response is the updated authorization. An eligible human records a decision bound to the exact intent digest.
Queue an execution
Access: developer key with execute scope plus a new signed envelope. Use action: purchase.execute, a fresh nonce, the authorization ID, and the original intent digest. Keep organization, agent, and delegation bound to that authorization.
Execution uses the stored purchase. A client cannot change the recipient or amount by altering an execution request. The authorization must still be executable and unexpired.
{
"id": "auth_EXAMPLE",
"status": "pending"
}202 means queued. The worker rechecks revocation and expiry, then records the outcome. Refresh the workspace or read GET /v1/state with a human session for current status. Use the returned provider_reference to correlate an execution with its outcome.
A distinct second execution attempt is rejected. An exact replay returns the original response, which may still say pending even after the current state has advanced.
Developer keys
Access: owner session. Choose the minimum scopes your integration needs.
{
"name": "Procurement integration",
"scopes": [
"authorize",
"execute"
],
"per_minute": 30,
"per_day": 1000
}201 response: {"key": {...metadata}, "token": "trl_dev_…"}. Save the token immediately. It is returned only once; the server stores its hash. Default expiry is 30 days; optional expires_at can extend this to at most 90 days.
| Method / route | Purpose |
|---|---|
| GET /v1/developer-keys | Latest 100 metadata records; never returns secret values. |
| POST /v1/developer-keys/{id}/revoke | Send {}. Idempotently block new admissions using that key. |
| GET /v1/developer-usage | Inspect current key, organization, and service usage buckets. |
At most 10 active keys per organization. Per-key limits can be lower than the defaults or raised to the fixed maxima of 60/minute and 5,000/day. Shared limits still apply. Revoking a developer key does not cancel an already queued purchase; revoke its delegation when that is the intended action.
Usage limits & backpressure
Current limits are enforced before signature and domain work, in persistent PostgreSQL counters. They survive restarts and apply across replicas.
| Scope | Per UTC minute | Per UTC day |
|---|---|---|
| New developer key | 30 | 1,000 |
| Organization, across all keys and browser-signed actions | 60 | 5,000 |
| Entire service | 600 | 50,000 |
All admitted attempts count, including retries, subsequent signature failures, and policy denials. A rejected quota check does not increment any bucket. Windows are fixed UTC windows, not rolling averages.
HTTP/1.1 429 Too Many Requests Retry-After: 42
{"error":{"code":"usage_limit_exceeded","message":"usage_limit_exceeded"}}
Wait at least Retry-After seconds, add jitter, and resend the original envelope if still appropriate. The HTTP server also limits request bodies to 64 KiB, headers to 16 KiB, concurrent requests to 32, and request work to 15 seconds.
Errors, retries & uncertain outcomes
Log the X-Request-ID response header with the operation and authorization ID. Do not log private keys or bearer tokens.
{"error":{"code":"nonce_reused","message":"nonce_reused"}}| HTTP | Typical cause | What to do |
|---|---|---|
| 400 | Malformed payload or invalid fields | Correct the request; inspect the error code. |
| 401 | Missing/invalid key, session, or agent signature | Check credentials and exact signing bytes. |
| 403 | Wrong scope, Origin, action, audience, or delegation | Correct authority or configuration; do not retry blindly. |
| 404 | Resource unavailable in this organization | Verify IDs and organization membership. |
| 409 | Nonce reuse, digest mismatch, or non-executable state | Inspect current state; do not create a replacement purchase. |
| 415 | Wrong Content-Type | Use application/json. |
| 429 | Usage allowance or login throttle | Honor Retry-After. |
| 500 / 503 | Service failure or paused API | Inspect code; retry transient failures with backoff and the same envelope. |
Idempotency lives in the signed nonce
The nonce namespace is shared across authorization and execution for each organization + agent. Repeating the same nonce with the same signed payload returns the original response. Different payload bytes with that nonce return 409. Do not invent an Idempotency-Key header; this API uses the signed envelope.
A timeout is not a failed payment
Keep the original envelope and retry it after transient transport failures. Check current workspace state as a human when the outcome is unclear. Do not submit a fresh purchase because you did not receive the first response.
Intent and decision evidence
The agent signs its exact intended action. Trellis records the evaluation against the agent’s delegation and policy. Keep these artifacts distinct: a signed request establishes what the signer requested; the event trail records what Trellis decided and what happened afterward.
Bind the organization, agent, delegation, action, audience, nonce, expiry and request terms. Execution references the authorization ID and original intent digest. An approval must refer to that same digest.
A signature is evidence of key possession and byte integrity. Use the decision status and current authority to determine the next step; an authorization alone is not proof of settlement.
Verify signed audit evidence →Export and verify evidence
Access: authenticated organization member. Export the full event chain and a signed checkpoint from the workspace. Events bind organization, actor, sequence, predecessor digest, time, and decision data.
Returns the authority’s public_key and key_id. Obtain and pin this public key through an independently trusted channel.
Event signatures use trellis-event-v1 plus a newline; checkpoint signatures use trellis-checkpoint-v1 plus a newline. Retain checkpoints outside the application to detect rollback to an older otherwise valid history.
Reconcile outcomes, preserve exceptions
The workbench compares execution records with imported provider observations. It is two-way transaction reconciliation, not bank-balance reconciliation or a financial ledger.
| Method / route | Access / purpose |
|---|---|
| POST /v1/reconciliation/import | Owner session. Import 1–100 normalized events atomically. |
| POST /v1/reconciliation/runs | Owner/approver session. Send {} to save an immutable snapshot. |
| GET /v1/reconciliation | Human session. Read recent runs and review notes. |
| POST /v1/reconciliation/reviews | Owner/approver session. Append an attributed investigation note. |
| GET /v1/reconciliation/example-feed | Human session. Download an example feed for the workflow. |
{
"events": [
{
"event_id": "demo-settled-1",
"reference": "PROVIDER_REFERENCE_FROM_EXECUTION",
"revision": 1,
"status": "settled",
"amount_minor": 26000,
"fee_minor": 0,
"currency": "USD",
"supplier_id": "northstar",
"occurred_at": 1790000000
}
]
}Set occurred_at to the event time. Revisions are contiguous per payment, starting at 1. An initial settled or failed record is allowed; a returned record needs prior settlement. Identical normalized duplicates are skipped. Conflicting event IDs are retained and surfaced as exceptions.
Matching checks reference, amount, currency, recipient, and history. Missing records remain unresolved. Fees are reported separately, not independently validated. Review notes acknowledge investigation; they do not overwrite a finding or move money. Example feeds derived from internal orders are not independent settlement evidence.
API contract
Download the OpenAPI document from your hosted workspace for exact routes, request fields, and response schemas. These integration examples use the deployed v1 HTTP contract.
curl -sS "$TRELLIS_URL/openapi.json" -o trellis-openapi.jsonKeep your client aligned with that workspace’s contract. Developer credentials authenticate integration access; registered agent signatures and delegations authorize actions.
Discuss an integration ↗