Skip to main content

Apps Overview

The Talonic Apps surface: named, versioned decision apps over governed data, with an ETag-cached catalog, dual auth heads, and a read/run/operate/configure ladder.

Apps are named, versioned, publishable decision units. An app reads a governed set of inputs — published data products, reference tables, connected databases, or other apps — and produces schema-typed decisions with a replayable, provenance-carrying ledger: an append-only run journal, a queryable run projection, and an immutable sealed decision record per run. Every app runs in one of three autonomy modes: rules (deterministic policy cards decide), assisted (rules decide, a draft-only agent proposes review resolutions), or external (an outside agent decides inside Talonic's contract and ledger).

The REST surface spans these route families: /v1/apps (catalog, lifecycle, logic, versions, acceptance, presentation and pinning, runs, standing decisions, reviews, rule candidates, precedents, grants, clients, plus hosted-app registration and installs and embedded surface sessions), /v1/apps/manifest/validate (public manifest validation, no credential), /v1/runs (run projection, verdict matrix, summary, journal, sealed record), /v1/replays, /v1/reviews (the Human Review inbox), /v1/rule-candidates, /v1/review-teams, and /v1/decision-tasks (the External-mode protocol). The Talonic web UI is a client of exactly these endpoints — it holds no private capabilities — and every enabled app is additionally published as a dynamic MCP tool named in its manifest's endpoints.mcp_tool (app_<slug>, with - replaced by _), so an external agent given only a token discovers and operates the same surface a person sees.

Authentication is dual-headed: tlnc_ API keys and OAuth tokens on one head, platform session JWTs on the other, both landing on the same authorization layer. Authorization is a four-tier ladder — read < run < operate < configure (= owner) — mapped for humans from the workspace role (viewer → read, member → run, Senior Member (senior_member) → operate — resolving reviews, replays, and the disable kill switch, owner → configure — creating, editing drafts, publishing, enabling, and promoting rule candidates). Machine callers are decided by per-app grants: a read grant admits reads, an operate grant admits read/run/operate and the configure-tier actions, and true owner-tier routes (grant management, deletion, pickable clients) always require a human workspace owner — they are never machine-grantable. An OAuth token issued to a person carries that person's workspace role, and the tier it reaches is the lower of what its scopes admit and what the role admits: a token can never outrank the person it was issued to.

A compatibility rule keeps pre-Apps keys working: a key with zero app-grant rows falls back to its legacy scopes — read scope for read-tier routes, write for run/operate. The moment the first grant row lands on a key, that key switches to strict per-app enforcement: it can only touch apps it holds grants for, at the granted level. OAuth clients and routes without an app id in the path (the workspace review inbox, /v1/runs/:runId) always use the scope path, with every read tenant-scoped by the service. On that path an OAuth client reaches read with apps:read and run/operate with apps:operate; the configure tier stays reachable only through legacy write.

The whole surface requires a concrete workspace. A key or session resolving to the cross-tenant master view is rejected with 403 and the message A concrete workspace is required for the Apps surface — there are no cross-tenant app reads or writes on /v1.

Granting a key its first per-app grant is a behavioral switch, not just an addition: the key immediately loses legacy scope-based access to every other app in the workspace. Grant deliberately — start with read, widen to operate per app.
GET/v1/apps

Query parameters & headers

enabledstringPass enabled=true to return only enabled apps (the set exposed as dynamic MCP tools).
If-None-MatchheaderThe etag from a previous response. When the catalog is unchanged the endpoint answers 304 Not Modified with an empty body.

The response carries an etag both as an ETag header and in the body: a hash across each visible app's id, active content hash, enabled flag, pin state, display name, icon, and installed surface URL. It changes when an app is created, deleted, enabled, disabled, republished, renamed, re-marked, pinned or unpinned, or installed — which makes it the cache key for tool catalogs. MCP clients re-list tools when it moves; your integration can poll GET /v1/apps with If-None-Match cheaply and rebuild only on 200.

curl

curl -s https://api.talonic.com/v1/apps \
  -H "Authorization: Bearer tlnc_your_api_key"

# Conditional re-fetch: 304 when nothing changed
curl -s -o /dev/null -w "%{http_code}" https://api.talonic.com/v1/apps \
  -H "Authorization: Bearer tlnc_your_api_key" \
  -H 'If-None-Match: "e3b0c44298fc1c149afbf4c8996fb924"'

Response (200)

{
  "apps": [
    {
      "app_id": "app_8f591d2d85c5990a",
      "id": "80afca3e-c66c-4f07-abbf-78913ee80dff",
      "slug": "invoice-approval",
      "display_name": "Invoice approval",
      "description": "Holds supplier invoices that drift from their purchase order.",
      "version": 1,
      "content_hash": "9df5a562cff6d2dfda738f4bf8f88b0b...",
      "mode": "rules",
      "enabled": true,
      "display_icon": null,
      "nav_pinned": true,
      "nav_pinned_at": "2026-09-02T08:10:00.000Z",
      "trigger_floor_at": null,
      "created_at": "2026-08-29T11:31:36.698Z",
      "updated_at": "2026-08-29T11:32:32.724Z",
      "contract": { "reads": [], "acts": [] },
      "output_contract": {
        "type": "object",
        "required": ["decision"],
        "properties": { "decision": { "enum": ["approve", "hold"] } }
      },
      "input_schema": {
        "type": "object",
        "properties": {
          "input": { "type": "object" },
          "idempotency_key": { "type": "string", "maxLength": 64 },
          "dry_run": { "type": "boolean" }
        }
      },
      "triggers": { "api": true, "manual": true },
      "thresholds": { "auto_execute_above_confidence": 0.9 },
      "fallback": "hold",
      "display": null,
      "endpoints": {
        "mcp_tool": "app_invoice_approval",
        "rest": "/v1/apps/80afca3e-c66c-4f07-abbf-78913ee80dff"
      },
      "surface": { "embed": "link", "scopes": ["apps:read"], "context": [], "url": null },
      "required_grants": [],
      "subscriptions": [],
      "subscriptions_endpoint": null,
      "source": "pushed",
      "manifest_url": null,
      "identity": null,
      "requires": [],
      "allowed_origins": []
    }
  ],
  "etag": "\"e51b28d3552a4f39a4c88b1e02e7a3d1\""
}
GET/v1/apps/:id
The manifest is the app as humans and agents both see it. contract.reads lists the input binding aliases of the active version, contract.acts the actions the app may execute, and display carries the app's own presentation vocabulary (subject label, headline metrics) so every surface — UI, MCP, your integration — speaks the app's words. The surface, required_grants, subscriptions, requires, and allowed_origins fields describe an installable app; see Hosted Apps & Installs.

The decide grant

Beside the ladder sits decide, the only authority on the decision-task protocol that External-mode apps use. It is an independent grant, not a rung: operate does not imply it, it implies nothing else, and no legacy scope reaches it. Human web sessions are refused outright — the protocol is a machine surface. A tlnc_ key needs an explicit per-app decide client grant. An OAuth session needs the apps:decide scope, which is never pre-consentable (a person consents to it in person, every time) and is checked against the person's live workspace membership on every request: the membership must be active and at Senior Member or above, so removing or demoting someone takes effect immediately rather than when their token expires.

Refusal reasons

Every refusal an integration must branch on carries a stable top-level reason beside code in the error envelope. Branch on reason, never on error or the message text: code only names the HTTP family, and messages are prose. On the Apps surface the ladder refusals read unauthenticated, workspace_required, insufficient_tier (with details.required naming the tier), owner_required, insufficient_app_grant, insufficient_scope (with details.required_scopes), and decide_grant_required; install and hosted-manifest refusals add reasons such as install_pending_approval, install_not_ready, slot_unbound, and hosted_manifest_read_only. An app's own service key always receives insufficient_grant for anything its approved grants do not cover, so it branches once for "the owner must grant this".

Response (403) — a refusal with its reason

{
  "statusCode": 403,
  "code": "INSUFFICIENT_PERMISSIONS",
  "reason": "insufficient_tier",
  "error": "forbidden",
  "message": "This action requires the 'configure' tier; your role is 'member'.",
  "retryable": false,
  "details": { "required": "configure" },
  "request_id": "req_5c1e0b7a9f2d4e18",
  "path": "/v1/apps/80afca3e-c66c-4f07-abbf-78913ee80dff/versions/3/publish"
}

Webhook events

Apps emit workspace webhooks through the standard HMAC-signed delivery pipeline: app.run.completed and app.run.failed when a triggered run reaches a terminal state, app.review.raised and app.review.resolved around Human Reviews, app.version.activated when a publish lands, app.action.execute when a decision executes an action (the payload carries the decision, its evidence locators, rationale, and the action's idempotency key), and app.exception.raised for system-raised exceptions. Subscribe via POST /v1/webhooks exactly as for document events.

  • app.decision_task.offered — an External-mode run parked a decision task; carries task_id, run_id, app, version, subject_count, and dry_run on a trial run. Also published to Delivery in the same transaction as the task row.
  • app.run.sealed — a run minted its sealed decision record; carries run_id, app, version, status, plus record_set_id and subjects when the record names them. Delivery only: subscribe through a delivery binding, not /v1/webhooks. Dry runs never emit it.
  • app.install.suspended / app.install.resumed — an installed app stopped serving (suspended by the workspace or its service key revoked) or is serving again.
  • app.version_fetched — a hosted app's manifest URL served a changed document, stored as a fetched candidate; nothing was activated. Carries app_id, app, version, content_hash.
  • app.subscription.suspended — an installed app's event subscription was suspended after consecutive delivery failures to its endpoint.

Frequently asked questions

What is the difference between the read, run, operate, and configure tiers?+
read covers every GET (manifests, runs, ledger, reviews, health). run adds triggering runs. operate adds resolving non-approval reviews, replays, raising reviews, and disabling (the kill switch). configure adds authoring: create, edit drafts, publish, activate, enable, rename, pin, and promote rule candidates — for a human session this is the workspace owner role; a machine key reaches it with an operate grant. Enabling adds its own rule on top: an app that declares acts needs a human workspace owner regardless of tier. decide is separate from all four. True owner-tier routes — grant and client management, review-team writes, and app deletion — are always a human workspace owner, never an API key.
My existing workspace key worked yesterday and gets 403 today. What changed?+
Most likely someone created the key's first per-app grant. A key with zero grants falls back to legacy read/write scopes across all apps; the first grant row flips it to strict per-app enforcement, so it now needs an explicit grant on each app it touches. Either grant it the apps it needs or remove its grants to restore legacy behavior.
How do I know when the app catalog changed?+
Use the etag. GET /v1/apps returns an ETag header (also echoed in the body) hashed over each visible app's id, active content hash, and enabled flag. Re-poll with If-None-Match: a 304 means nothing changed; a 200 means an app was created, deleted, enabled, disabled, or republished — rebuild your cached catalog or MCP tool list then.
Why do I get 403 "A concrete workspace is required"?+
Your credential resolves to the cross-tenant master view (customerId "all"), which the Apps surface rejects for reads and writes alike. Call with a key or session bound to one concrete workspace.
Can an agent manage grants over the API?+
No. Every owner-tier route — POST/DELETE grants, POST/DELETE clients, review-team writes, app deletion, pickable-clients — requires a human session with the owner role. Machine callers receive 403 with "Grant and client management requires a human workspace owner."