Decision Tasks
Decide External-mode app runs from your own agent: receive or poll offered tasks, claim a lease, read the frozen package, heartbeat, and submit decisions.
In External mode an app's decision is made by your own service, inside Talonic's contract and ledger. When a run starts, the platform assembles and freezes the input package exactly as it would for a rules app, parks the run at awaiting_decision, and offers a decision task. Your service claims the task, reads the package, decides, and submits an outcome with evidence and a rationale; the platform verifies the submission against the app's output contract and the frozen package, records decided_by as an external agent, and resumes the run through thresholds, actions, and sealing. If no decision arrives within the app's SLA (logic.decisionSlaSeconds, with the lease length in logic.leaseSeconds), the app's declared fallback takes over: rules runs the resident rule set, hold raises a review and holds, none fails the run explicitly.
Every route here needs the decide grant on the app and is machine-only — human web sessions are refused. A tlnc_ key needs an explicit per-app decide client grant (an installed app's service key holds one on its own app); an OAuth session needs the apps:decide scope and a live workspace membership at Senior Member or above, checked on every request. decide implies nothing else and operate does not imply it, so a decision worker can be granted exactly this and no more. Refusals carry reason: decide_grant_required, insufficient_scope, or insufficient_tier.
Tasks arrive by push or by poll. The app.decision_task.offered event carries task_id, run_id, app, version, subject_count (records in the frozen package), and dry_run on a trial run; it goes to /v1/webhooks subscriptions and is published to Delivery in the same transaction as the task row. GET /v1/apps/:id/decision-tasks is the polling alternative, newest first, filterable by status (available, claimed, submitted, released, failed, timed_out, cancelled) and paginated with limit and cursor. While an install is suspended no offers go out; the task rows wait for it to resume.
Claim, read, heartbeat
/v1/decision-tasks/:id/claim/v1/decision-tasks/:id/packagePOST /v1/decision-tasks/:id/heartbeat with { "execution_epoch": N } extends the lease, never past the task's SLA deadline. The epoch is the concurrency guard: a reclaim or a suspension bumps it, so a stale worker's heartbeat or submit answers 409 and journals nothing, while the current claimant carries on. A lapsed lease is also a 409 on heartbeat and submit. The task metadata in every response shows status, execution_epoch, lease_seconds, lease_expires_at, sla_deadline_at, and who claimed it when.
curl — poll, claim, read
curl -s "https://api.talonic.com/v1/apps/$APP_ID/decision-tasks?status=available&limit=10" \
-H "Authorization: Bearer $DECIDE_KEY"
curl -s -X POST https://api.talonic.com/v1/decision-tasks/$TASK_ID/claim \
-H "Authorization: Bearer $DECIDE_KEY"
# → { "task": { "id": "…", "status": "claimed", "execution_epoch": 1, "lease_seconds": 120,
# "lease_expires_at": "…", "sla_deadline_at": "…", ... },
# "output_contract": { ... },
# "precedents": [ ... ],
# "package": { "package_kind": "…", "record_count": 214, "page_size": 500,
# "first_cursor": "…", "documents": [ ... ] } }
curl -s "https://api.talonic.com/v1/decision-tasks/$TASK_ID/package?cursor=$CURSOR" \
-H "Authorization: Bearer $DECIDE_KEY"Submit, release, fail
POST /v1/decision-tasks/:id/submit closes the task. The body carries execution_epoch, outcome, evidence (an array of provenance locators), a mandatory rationale (up to 4000 characters — a stated reason, never private chain-of-thought), an optional confidence between 0 and 1, and an optional service_version that lands in decided_by.label. The outcome must validate against the task's output contract: a single-decision app's own JSON Schema, or for a verdict-matrix app the envelope { "subjects": [{ "subject_key", "rule_outcomes", "auto"?, "detail"? }] }. Every evidence locator must be checkable: either it exists in the frozen package, or it is a db: or ref: locator on a resource the app holds a read grant on — recorded as an attestation, never resolved. A locator on anything the app was never granted is refused. Empty evidence is accepted only when the app's logic sets allowUnevidenced. Every refusal answers 422 and journals a rejected event; submit is idempotent per claimant and epoch.
Submit (verdict-matrix app)
POST /v1/decision-tasks/$TASK_ID/submit
{
"execution_epoch": 1,
"outcome": {
"subjects": [
{ "subject_key": "INV-2026-0851", "auto": false,
"rule_outcomes": { "po-price-match": { "status": "failed", "reason": "billed 1249.90 vs purchase order 1180.00" } } }
]
},
"evidence": ["dp:dp_invoices_v2@v3:row_8851:total_amount", "ref:ref_purchase_orders:1182:amount"],
"rationale": "Billed amount exceeds the purchase order by 5.9%, above the 2% tolerance.",
"confidence": 0.97,
"service_version": "po-agent@2.3.1"
}
→ 200 { "id": "…", "status": "submitted", "execution_epoch": 1, "submitted_at": "…", ... }POST /v1/decision-tasks/:id/release with the epoch gives a task back voluntarily: it returns to available and the next claim bumps the epoch. POST /v1/decision-tasks/:id/fail with the epoch and a reason (up to 2000 characters) says "I cannot decide this": it terminates the task, raises a Human Review carrying the reason, and applies the app's fallback policy exactly as an SLA expiry would. Fail is accepted even after the lease has lapsed, because reporting failure must always stay possible.