Skip to content

Integration Guide

This guide is the front door to the shiftagent Integration API — the contract between a host system’s adapter and an on-prem shiftagent deployment. Read it once, end to end, and you will know how the pieces fit; the companion documents then go deep on each piece (see Where to go next). The OpenAPI specification at openapi/openapi.yaml is normative for every operation named here — this guide references operations by operationId and method/path, and copies its examples verbatim from the spec.

The one-sentence model:

The host system fronts all end-user traffic; a stateless, client-specific adapter derives identity from the host’s own JWT and calls the Integration API; shiftagent’s database holds all mapping state, so the adapter never needs storage of its own.

Host system ── host JWT ──▶ Adapter (stateless) ── sk_int_… key ──▶ shiftagent Integration API

Four layers, one direction of trust:

flowchart LR
  subgraph host["Host system"]
    endUser["End user"]
    hostApp["Host product UX"]
    hostAuth["Host auth service<br/>(issues host JWTs)"]
    endUser --> hostApp
    hostAuth -.-> hostApp
  end

  subgraph adapter["Adapter — stateless middleweight"]
    derive["deriveIdentity()<br/>host JWT → external IDs"]
    orchestrate["Provisioning orchestration<br/>(cold/warm path)"]
    transport["Stream + approval transport"]
  end

  subgraph platform["shiftagent (on-prem)"]
    api["Integration API"]
    db[("Platform DB<br/>all mapping state")]
    subgraph runtime["Runtime"]
      pool["Scheduler +<br/>sandbox pool<br/>(warm / sticky)"]
      sandbox["Per-run sandboxes<br/>(zero credentials)"]
    end
    vault[("Vault<br/>credentials + secrets")]
    egress["Egress proxy<br/>(alias → value at the<br/>network boundary)"]
    git[("Git repositories<br/>skills, agent config")]
    storage[("S3-style storage<br/>user + conversation buckets")]
  end

  external["External systems<br/>(CRM, warehouse, BI, …)"]

  hostApp -- "host JWT" --> derive
  derive --> orchestrate --> api
  transport <--> api
  api --> db
  api --> pool --> sandbox
  git --> sandbox
  storage --> sandbox
  sandbox --> egress
  vault --> egress
  egress --> external

The properties that make this shape work:

PropertyWhat it meansWhere it comes from
Stateless adapterThe adapter holds no database and no mapping tables. Every identity question is answered by shiftagent via external_id lookups and upserts.by-external-id subresources on tenants and users; getIntegrationSelf for zero-config bootstrap.
Middleweight, not thinThe adapter carries real orchestration logic — identity derivation, idempotent provisioning, stream pass-through, approval transport — it just never persists anything.Adapter Design Spec.
Idempotent provisioningEvery provisioning call can be replayed safely; races collapse deterministically. Crashed cold paths heal on the next request.PUT merge-upsert semantics + 409 name-conflict recovery (Provisioning Flow).
Zero-trust runtimeThe agent runtime never holds real credentials. Secrets are vaulted at the API boundary; the agent sees only aliases; the egress proxy resolves them on outbound calls.Conversation secrets + credential registry (§4), Runtime Architecture.
Two-party approval controlHuman-in-the-loop approvals require a signed assertion minted with a per-tenant approver key — the adapter can transport an approval but can never mint one.Approvals (§4.7), approveApproval / denyApproval.
Everything composed per requestRepository, skills, agent runtime, sandbox placement, filler, and capacity strategy are all resolved per conversation/message from data the API manages — nothing baked in.Resolution cascade (§5), Runtime Architecture.

Two bearer credentials exist, plus one signing key that is deliberately not a bearer credential. Every operation in the spec documents which it accepts.

CredentialShapeWho holds itWhat it is for
Integration keysk_int_… bearer token (integrationKey scheme)The adapter, as a service principalEverything: provisioning, registry management, tenant-wide reads, conversations, approvals transport
Platform JWTShort-lived JWT (platformJwt scheme), obtained via tokenExchangeA single end user’s request contextConversation and message operations scoped to that one user
Approver keyHMAC-SHA256 or Ed25519 signing key, registered out-of-bandThe host’s approval authority — never the adapterMinting the signed assertions that resolve HITL approvals (§4.7)

The integration key is a role-mode service principal: there is no user directory behind it — it acts as the integration itself. It is subtree-scoped: the key is bound to the integration’s root tenant, every tenant it provisions is created as a child of that root, and the key can never see or touch anything outside its subtree. A resource outside the subtree is indistinguishable from a missing one (404 not-found).

All operations accept the integration key. Tenant-scope work — provisioning, registry management, tenant-wide conversation listing (listConversations with ?tenant_id=), reconciliation sweeps — runs under it directly.

2.2 Per-user context: token exchange, not acting-as

Section titled “2.2 Per-user context: token exchange, not acting-as”

For calls made on behalf of one end user, the adapter trades the user’s external IDs for a short-lived platform JWT via tokenExchange (POST /auth/token-exchange) and forwards the request under that token. There is no acting-as header in this API: user context is always carried by the token itself, which keeps the audit trail honest and the authorization model single-pathed.

sequenceDiagram
  autonumber
  participant H as Host system
  participant A as Adapter
  participant S as Integration API

  H->>A: request + host JWT
  A->>A: deriveIdentity() → external tenant + user IDs
  A->>S: POST /auth/token-exchange (sk_int_…)
  S-->>A: platform JWT (expires_at)
  A->>S: POST /conversations/{id}/messages (platform JWT)
  S-->>A: NDJSON event stream
  A-->>H: pass-through

Token-exchange facts worth memorizing:

  • Both the tenant and the user must already existtokenExchange never provisions (404 for unknown IDs). Upsert first (§6).
  • Suspended tenant or deactivated user → 403. This is the lazy-enforcement leg of lifecycle reconciliation: deprovisioned identities fail here even before any sweep runs.
  • Default TTL 15 minutes, max 60 (ttl_seconds). The adapter may cache the JWT until expires_at but must never persist it.
SurfaceintegrationKeyplatformJwt
Tenants, repositories, credentials, roles, users (all provisioning + registry + directory operations)
listConversations, createConversation, getConversation, updateConversation, archiveConversation, listMessages, createMessage✔ (tenant-wide reach)✔ (scoped to the token’s user — e.g. listConversations permits only ?user_id= matching the token, else 403 insufficient-scope)
Conversation secrets (putConversationSecrets, listConversationSecrets, deleteConversationSecret)
Approvals (listApprovals, getApproval, approveApproval, denyApproval)✔ (transport; resolution additionally requires the signed assertion)
tokenExchange, getCapacity, getIntegrationSelf
getHealth (GET /health)unauthenticated — the only such operation

2.4 Zero-config bootstrap: getIntegrationSelf

Section titled “2.4 Zero-config bootstrap: getIntegrationSelf”

An adapter starts with exactly one configuration value that matters: its sk_int_… key. Everything else is discoverable. getIntegrationSelf (GET /integration/self) introspects the key and returns its principal — root tenant, granted scopes, and the registered approver keys (public metadata only, never secret material):

{
"object": "integration_principal",
"key_id": "key_01hzx8int001",
"name": "host-adapter",
"root_tenant_id": "tnt_01hzx8root001",
"scopes": [
"tenants:write",
"users:write",
"roles:write",
"repositories:write",
"conversations:read_all",
"conversations:write"
],
"approver_keys": [
{
"key_id": "apk_01hzx8host001",
"algorithm": "hmac-sha256",
"created_at": "2026-07-01T12:00:00Z"
}
]
}

Call it at startup to verify configuration, discover the root tenant, and learn which signature.key_id values are valid for approval resolution — with zero local state.

Tenants and users are addressed by the host system’s own identifiers through by-external-id subresources. This is what makes the adapter stateless: it never maps host IDs to shiftagent IDs in a table of its own — it just asks (or upserts) by external ID.

External IDs MUST be namespaced by the adapter at derivation time, so multiple host systems (or multiple environments of one host system) can never collide inside one shiftagent install:

EntityConventionExample
Tenant{ns}:tenant:{host_tenant_id}acme:tenant:128231
User{ns}:user:{host_user_id}acme:user:9f27c1

The namespace {ns} is adapter configuration — one value per host system per environment (e.g. acme for production, acme-staging for staging).

shiftagent treats external IDs as opaque strings: compared byte-exact after trimming, max 255 characters, case-sensitive. It never normalizes. Therefore the adapter owns canonicalization and must apply it identically on every path that produces an external ID:

  • Pick one casing rule for host IDs that are case-insensitive in the host system (e.g. lowercase GUIDs) and apply it in exactly one place — the adapter’s deriveIdentity() function.
  • Never derive the same host identity into two byte-different external IDs; shiftagent will faithfully create two resources.
  • URL-encode reserved characters when the external ID appears in a path segment (:%3A where the client’s HTTP library requires it).
FieldUnique withinEnforced by
tenant.external_idthe whole integration (the key’s subtree)DB uniqueness constraint — the concurrency lock behind upsertTenantByExternalId
user.external_idone tenantper-tenant uniqueness constraint behind upsertUserByExternalId

Uniqueness constraints are also the race resolution mechanism: concurrent upserts of the same external ID collapse — the winner gets 201, the loser gets 200 with the winner’s record, and no 409 is possible on the upsert path. See Provisioning Flow for the full race semantics.

flowchart TB
  subgraph registry["Integration-root registry (top-level, shared)"]
    CRD["Credential crd_<br/>write-only secret"]
    REP["Repository rep_<br/>pre-authenticated git registry entry"]
    SKL["Skill skl_<br/>dictated by the repository"]
    CRD -->|credential_id| REP
    REP -->|dictates| SKL
  end

  TNT["Tenant tnt_<br/>external_id, settings,<br/>default_repository_id"]
  ROL["Role rol_<br/>repository override +<br/>skill_access"]
  USR["User usr_<br/>external_id, role_ids,<br/>storage bucket"]
  CON["Conversation con_<br/>context snapshot, runtime,<br/>storage bucket, secrets"]
  MSG["Message msg_<br/>env, secrets, per-message<br/>repo/skill overrides"]
  APR["Approval apr_<br/>HITL gate, signed resolution"]

  REP -->|attached to| TNT
  TNT --> ROL
  TNT --> USR
  ROL -->|assigned to| USR
  USR --> CON
  CON --> MSG
  MSG -->|may raise| APR

A tenant mirrors one host-system tenant and is the unit of isolation. Its natural key is external_id; PUT /tenants/by-external-id/{external_id} (upsertTenantByExternalId) is the cold/warm provisioning primitive — an empty body {} is valid, so a tenant can be created with nothing but its external ID and enriched later. tenant.settings carries runtime defaults and caps (filler_enabled, default_agent_type, max_sticky_ttl_seconds, max_concurrent_sticky) — downstream scopes can only narrow within them. default_repository_id is the fall-through of the resolution cascade (§5).

OperationMethod & path
upsertTenantByExternalIdPUT /tenants/by-external-id/{external_id}
getTenantByExternalIdGET /tenants/by-external-id/{external_id}
deleteTenantByExternalIdDELETE /tenants/by-external-id/{external_id} — the reconciliation-path deprovision: cascades over conversations, users, attachments, and vaulted secrets in one call
listTenants / createTenantGET / POST /tenants
getTenant / updateTenant / deleteTenantGET / PATCH / DELETE /tenants/{tenant_id}

A top-level, vault-style registry of external-system credentials — git access tokens today, warehouse/BI credentials later (§7). The secret field of createCredential (POST /credentials) is write-only: vaulted on arrival, never returned by any operation. Everything else references credentials by crd_ ID only. Deletion (deleteCredential, DELETE /credentials/{credential_id}) is guarded: 409 resource-in-use while anything references the credential — to rotate, register the new one, repoint, then delete the old one. listCredentials (GET /credentials) lists handles — names, types, metadata — never material.

4.3 Repository registry (rep_) and skills (skl_)

Section titled “4.3 Repository registry (rep_) and skills (skl_)”

Repositories are top-level, pre-authenticated registry entries — scoped to the integration root, not tenant-owned. Registering one (registerRepository, POST /repositories) supplies name (registry-unique), repo_url, branch, provider, and a credential_id — after which nobody downstream ever handles git credentials again. Registration starts an asynchronous sync (sync.state: pending → syncing → ready | error) that scans the repository for skills.

The repository dictates which skills exist. Skills are read from the repository (listRepositorySkills, GET /repositories/{repository_id}/skills — cached by default, ?refresh=true for a fresh git read) or explicitly added (createRepositorySkill, POST /repositories/{repository_id}/skills, source: "manual"). syncRepository (POST /repositories/{repository_id}/sync) triggers a re-scan.

OperationMethod & path
registerRepository / listRepositoriesPOST / GET /repositories
getRepository / updateRepository / deleteRepositoryGET / PATCH / DELETE /repositories/{repository_id} — delete is guarded: 409 resource-in-use while attached or pinned anywhere
syncRepositoryPOST /repositories/{repository_id}/sync
listRepositorySkills / createRepositorySkillGET / POST /repositories/{repository_id}/skills

Repositories reach tenants through attachments:

OperationMethod & path
listTenantRepositoriesGET /tenants/{tenant_id}/repositories
attachTenantRepositoryPUT /tenants/{tenant_id}/repositories/{repository_id} — idempotent; {"is_default": true} also sets tenant.default_repository_id; branch_override reads a different branch for this tenant only
detachTenantRepositoryDELETE /tenants/{tenant_id}/repositories/{repository_id}409 resource-in-use while it is the default and dependents resolve to it

A role is a tenant-scoped access profile with exactly two levers:

  • repository_id — an optional repository override; null means the role resolves to the tenant’s default repository.
  • skill_access{"mode": "all"} or {"mode": "selected", "skill_ids": [...]}, narrowing within the role’s effective repository.

A role’s resolved grant is answered by listRoleSkills (GET /roles/{role_id}/skills): effective repository’s skills ∩ skill_access. Role name is unique per tenant, which is load-bearing for replay-safe provisioning: a crashed cold path that retries createRole (POST /tenants/{tenant_id}/roles) gets a deterministic 409 name-conflict carrying conflicting_resource_id, fetches that role, and continues. Also: listRoles (GET /tenants/{tenant_id}/roles, ?name= exact filter as the recovery path), getRole / updateRole / deleteRole (GET/PATCH/DELETE /roles/{role_id}).

A user belongs to one tenant and is provisioned by external ID exactly like tenants: upsertUserByExternalId (PUT /tenants/{tenant_id}/users/by-external-id/{external_id}), empty body valid, merge semantics provided → replaced, omitted → unchanged, null → cleared — so a warm-path refresh that omits role_ids never wipes role assignments.

On creation every user is automatically attached to an S3-style storage bucket (storage: {provider: "platform", bucket_uri: "s3://…"}); a host-owned bucket can be linked later via updateUser (storage.provider: "external"). A user’s effective skills are the union over its roles of each role’s resolved grant — answered with provenance (which role, which repository) by listUserSkills (GET /users/{user_id}/skills).

Deactivation is soft and deliberate: deactivateUser (DELETE /users/{user_id}) suspends the user — token exchange fails 403, new conversations are rejected, data is retained — and deactivated ≠ absent: upserts never resurrect a suspended user; only updateUser with status: "active" reactivates. This is what makes the reconciliation sweep safe.

OperationMethod & path
upsertUserByExternalId / getUserByExternalIdPUT / GET /tenants/{tenant_id}/users/by-external-id/{external_id}
listTenantUsersGET /tenants/{tenant_id}/users
listUsersGET /users — cross-tenant over the key’s subtree (?tenant_id=, ?email=, ?status=)
getUser / updateUser / deactivateUserGET / PATCH / DELETE /users/{user_id}
listUserRolesGET /users/{user_id}/roles
assignUserRole / unassignUserRolePUT / DELETE /users/{user_id}/roles/{role_id} — both idempotent
listUserSkillsGET /users/{user_id}/skills

4.6 Conversations (con_) and messages (msg_)

Section titled “4.6 Conversations (con_) and messages (msg_)”

A conversation is created for a user (createConversation, POST /conversations) and does two important things at birth:

  1. Snapshots its context — user → role → repository → skills, recorded in context {role_id, repository_id, skill_ids} so history is self-explaining even after roles or repositories change later. If the user holds multiple roles and no role_id is given, the API refuses with 422 role-required — no silent guessing.
  2. Fixes its runtime placementruntime.agent_type (open enum: claude-agent-sdk, codex, deepagent, …; defaults from tenant settings) and runtime.mode: pooled (default — each message claims a warm-pool sandbox) or sticky (a dedicated sandbox leased for sticky_ttl_seconds, refreshed per message, extendable or releasable via updateConversation). A per-conversation storage bucket is auto-attached.

Messages are sent with createMessage (POST /conversations/{conversation_id}/messages); the default response is a streaming application/x-ndjson body — one ConversationEvent per line, monotonic seq, exactly one terminal event (message_end or error); ?stream=false gives a blocking 201 JSON instead. The full event protocol lives in Streaming Contract. Each message carries per-run knobs — most specific wins in every cascade:

  • repository_id — one-shot repository override (§5).
  • skill_ids — narrow this run to specific skills so the agent context stays lean.
  • env — plaintext, non-secret run parameters. Never place secret material in env — the runtime sees these values verbatim.
  • secrets — write-only alias → value map, vaulted on arrival, conversation-scoped; the agent sees only {{secret:ALIAS}} placeholders, resolved by the egress proxy at the network boundary.
  • filler {enabled} — override the filler cascade (tenant setting → conversation → message); filler deltas arrive flagged data.filler: true so hosts can render or suppress them.
  • on_capacityreject (default): 429 capacity-exhausted + Retry-After when no sandbox is available; hold: the stream first emits queued events until a sandbox frees, bounded by the deployment’s max hold time. Pre-check pool state with getCapacity (GET /capacity).

Conversation-scoped secrets also have a standalone surface, independent of any message: putConversationSecrets (PUT /conversations/{conversation_id}/secrets), listConversationSecrets (GET, aliases and timestamps only — never values), and deleteConversationSecret (DELETE /conversations/{conversation_id}/secrets/{alias}).

Listing is service-principal-friendly: listConversations (GET /conversations) requires exactly one of ?user_id= or ?tenant_id= — one user’s conversations, or the whole tenant’s. History is listMessages (GET /conversations/{conversation_id}/messages). Archiving is soft (archiveConversation, DELETE /conversations/{conversation_id}): history stays readable, writes get 409 conversation-archived, sticky sandboxes are released, vaulted secrets are destroyed.

Mid-run, the agent may raise a human-in-the-loop gate: the stream emits approval_required carrying the Approval object — reason (the agent’s stated need) and requested_items[] ({kind: "action" | "secret", description, alias?}) — and the message parks in awaiting_approval. Hosts that poll instead of streaming use listApprovals (GET /approvals, e.g. ?status=pending&tenant_id=… as an inbox) and getApproval (GET /approvals/{approval_id}).

Resolution — approveApproval (POST /approvals/{approval_id}/approve) / denyApproval (POST /approvals/{approval_id}/deny) — requires a signed assertion: HMAC-SHA256 or Ed25519 over the canonical JSON {"approval_id":"…","decision":"approve|deny","exp":…}, minted with a per-tenant approver key registered out-of-band. The approver key is cryptographically distinct from the sk_int_ service key: the adapter transports approvals but can never mint one, and neither can a rogue agent — two-party control. The approve body may additionally supply requested secrets (alias → value, vaulted exactly like putConversationSecrets) — this is the approval⇄vault weave: the agent asks for an alias, the approver supplies the value, the run resumes seeing only the alias.

Which repository — and therefore which skills — an agent run gets is resolved through a five-level cascade, most specific wins:

message.repository_id
→ conversation.repository_id
→ user.default_repository_id
→ role.repository_id
→ tenant.default_repository_id

Effective skills at message time are then the intersection of every narrowing that is set:

effective repo's skills ∩ role.skill_access ∩ conversation.selected_skill_ids ∩ message.skill_ids

Setup (this mirrors the spec’s examples and the mock-server seed):

  • Registry: field-ops (rep_01hzx8fieldops, 5 skills, among them dispatch-scheduler and invoice-lookup) and dispatch-tools (rep_01hzx8dispatchtools, 2 skills).
  • Tenant Acme Field Services (tnt_01hzx8acme001) — field-ops attached with is_default: true.
  • Role csr (rol_01hzx8csr001) — repository_id: null, skill_access: {mode: "selected", skill_ids: [dispatch-scheduler, invoice-lookup]}.
  • Role dispatcherrepository_id: rep_01hzx8dispatchtools (override), skill_access: {mode: "all"}.
  • User Jane (usr_01hzx8jane001) — role csr, default_repository_id: null.
  • User Marco — role dispatcher.
#ScenarioLevel that winsEffective repositoryEffective skills
1Jane starts a plain conversationtenant defaultfield-opscsr’s 2 selected skills
2Marco starts a plain conversationrole overridedispatch-toolsall 2 dispatch-tools skills
3Jane’s user record gets default_repository_id: rep_01hzx8dispatchtools via updateUseruser override (outranks the role)dispatch-toolsdispatch-tools skills ∩ csr’s skill_access
4Jane starts a conversation with repository_id: rep_01hzx8dispatchtools in createConversationconversation overridedispatch-toolssnapshotted into context at creation
5One message in Jane’s plain conversation carries repository_id: rep_01hzx8dispatchtools and skill_ids: [skl_01hzx8invoice] in createMessagemessage override — this run onlydispatch-toolsnarrowed to the one listed skill; the next message falls back to scenario 1

Scenario 5 is the “magic thing for one command”: a single run borrows a different toolset without disturbing the conversation’s snapshot. Scenario 3 vs 2 shows why the order matters — a per-user pin is more specific than a role-wide default.

The same most-specific-wins philosophy governs the filler cascade (tenant.settings.filler_enabledconversation.fillermessage.filler) and capacity strategy (on_capacity per conversation-create and per message).

The complete call sequence, with request/response JSON copied from the spec. Two distinct phases: one-time setup (runs once per integration, typically by an operator or the adapter’s bootstrap) and the cold path (runs per host tenant, on first contact). The warm path — every subsequent request — is the same code path with the cold-path steps skipped by status code.

All requests carry Authorization: Bearer sk_int_… unless a platform JWT is called out.

getIntegrationSelfGET /integration/self → the principal shown in §2.4. Verify scopes, learn root_tenant_id and the registered approver_keys. No other configuration is needed.

6.1 One-time setup — credential, then repository

Section titled “6.1 One-time setup — credential, then repository”

Step 1 — createCredentialPOST /credentials

{
"name": "git-main-token",
"type": "git_pat",
"secret": "example-token-value-never-echoed",
"metadata": { "rotation": "quarterly" }
}

201 — the secret is vaulted and not echoed; only the handle comes back:

{
"object": "credential",
"id": "crd_01hzx8gitmain",
"name": "git-main-token",
"type": "git_pat",
"metadata": { "rotation": "quarterly" },
"created_at": "2026-07-02T09:30:30Z",
"updated_at": "2026-07-02T09:30:30Z"
}

Step 2 — registerRepositoryPOST /repositories

{
"name": "field-ops",
"repo_url": "https://git.example.com/agent-skills/field-ops.git",
"branch": "main",
"provider": "generic",
"credential_id": "crd_01hzx8gitmain"
}

201 — registered, pre-authenticated, async skill scan started:

{
"object": "repository",
"id": "rep_01hzx8fieldops",
"name": "field-ops",
"repo_url": "https://git.example.com/agent-skills/field-ops.git",
"branch": "main",
"provider": "generic",
"credential_id": "crd_01hzx8gitmain",
"sync": { "state": "syncing", "last_synced_at": null, "error": null },
"skill_count": 0,
"metadata": {},
"created_at": "2026-07-02T09:31:00Z",
"updated_at": "2026-07-02T09:31:00Z"
}

Poll getRepository (GET /repositories/{repository_id}) until sync.state: "ready", then inspect the catalog with listRepositorySkills (GET /repositories/{repository_id}/skills):

{
"object": "list",
"data": [
{
"object": "skill",
"id": "skl_01hzx8dispatch",
"repository_id": "rep_01hzx8fieldops",
"name": "dispatch-scheduler",
"description": "Plan and assign field-technician dispatch schedules.",
"version": "1.2.0",
"path": ".claude/skills/dispatch-scheduler/SKILL.md",
"source": "discovered",
"metadata": {},
"created_at": "2026-07-02T09:32:00Z",
"updated_at": "2026-07-02T09:32:00Z"
},
{
"object": "skill",
"id": "skl_01hzx8invoice",
"repository_id": "rep_01hzx8fieldops",
"name": "invoice-lookup",
"description": "Retrieve and explain customer invoices.",
"version": "1.0.3",
"path": ".claude/skills/invoice-lookup/SKILL.md",
"source": "discovered",
"metadata": {},
"created_at": "2026-07-02T09:32:00Z",
"updated_at": "2026-07-02T09:32:00Z"
}
],
"has_more": false,
"next_cursor": null
}

6.2 Cold path — first contact from a host tenant

Section titled “6.2 Cold path — first contact from a host tenant”

Step 3 — upsertTenantByExternalIdPUT /tenants/by-external-id/acme%3Atenant%3A128231

{
"name": "Acme Field Services",
"metadata": { "host_plan": "premium" }
}

201 — created; the status code is the branch signal. ({} would also have been a valid body — external-ID-only creation always succeeds.)

{
"object": "tenant",
"id": "tnt_01hzx8acme001",
"external_id": "acme:tenant:128231",
"name": "Acme Field Services",
"status": "active",
"default_repository_id": null,
"settings": {
"filler_enabled": true,
"default_agent_type": "claude-agent-sdk",
"max_sticky_ttl_seconds": 3600,
"max_concurrent_sticky": 5
},
"metadata": { "host_plan": "premium" },
"created_at": "2026-07-02T09:30:00Z",
"updated_at": "2026-07-02T09:30:00Z"
}

Step 4 — attachTenantRepositoryPUT /tenants/tnt_01hzx8acme001/repositories/rep_01hzx8fieldops

{ "is_default": true }

201 — attached and set as tenant.default_repository_id (the attachment embeds the full repository, now sync.state: "ready" with skill_count: 5).

Step 5 — createRolePOST /tenants/tnt_01hzx8acme001/roles

{
"name": "csr",
"description": "Customer service representative",
"skill_access": {
"mode": "selected",
"skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"]
}
}

201:

{
"object": "role",
"id": "rol_01hzx8csr001",
"tenant_id": "tnt_01hzx8acme001",
"name": "csr",
"description": "Customer service representative",
"repository_id": null,
"skill_access": {
"mode": "selected",
"skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"]
},
"created_at": "2026-07-02T09:35:00Z",
"updated_at": "2026-07-02T09:35:00Z"
}

If a previous cold path crashed after creating this role, the retry gets 409 name-conflict with conflicting_resource_id — fetch it with getRole and continue. That recovery rule is what makes the whole sequence replay-safe.

Step 6 — upsertUserByExternalIdPUT /tenants/tnt_01hzx8acme001/users/by-external-id/acme%3Auser%3A9f27c1

{
"email": "jane.doe@acme.example.com",
"display_name": "Jane Doe",
"role_ids": ["rol_01hzx8csr001"]
}

201 — created, with the platform storage bucket auto-attached:

{
"object": "user",
"id": "usr_01hzx8jane001",
"tenant_id": "tnt_01hzx8acme001",
"external_id": "acme:user:9f27c1",
"email": "jane.doe@acme.example.com",
"display_name": "Jane Doe",
"status": "active",
"role_ids": ["rol_01hzx8csr001"],
"default_repository_id": null,
"storage": {
"provider": "platform",
"bucket_uri": "s3://shiftagent-tenant-acme/usr_01hzx8jane001"
},
"metadata": {},
"created_at": "2026-07-02T09:36:00Z",
"updated_at": "2026-07-02T09:36:00Z"
}

Step 7 — tokenExchangePOST /auth/token-exchange

{
"tenant_external_id": "acme:tenant:128231",
"user_external_id": "acme:user:9f27c1",
"ttl_seconds": 900
}

200:

{
"object": "token",
"token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfMDFoeng4amFuZTAwMSJ9.example",
"token_type": "Bearer",
"expires_at": "2026-07-02T10:15:00Z",
"tenant_id": "tnt_01hzx8acme001",
"user_id": "usr_01hzx8jane001"
}

Step 8 — createConversationPOST /conversations (under the platform JWT)

{
"user_id": "usr_01hzx8jane001",
"title": "Invoice questions",
"metadata": { "host_ref": "ticket-4521" }
}

201 — note the resolved context snapshot (Jane’s single role, the tenant-default repository, the csr skill grant) and the auto-attached conversation bucket:

{
"object": "conversation",
"id": "con_01hzx8conv001",
"tenant_id": "tnt_01hzx8acme001",
"user_id": "usr_01hzx8jane001",
"title": "Invoice questions",
"status": "active",
"repository_id": null,
"context": {
"role_id": "rol_01hzx8csr001",
"repository_id": "rep_01hzx8fieldops",
"skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"]
},
"selected_skill_ids": null,
"runtime": {
"agent_type": "claude-agent-sdk",
"mode": "pooled",
"sticky_ttl_seconds": null,
"sandbox_state": "warm",
"expires_at": null
},
"filler": null,
"storage": {
"provider": "platform",
"bucket_uri": "s3://shiftagent-tenant-acme/con_01hzx8conv001"
},
"message_count": 0,
"last_message_at": null,
"metadata": { "host_ref": "ticket-4521" },
"created_at": "2026-07-02T10:00:00Z",
"updated_at": "2026-07-02T10:00:00Z"
}

(To collapse steps 8 and 9 into one round trip, pass initial_message to createConversation — the response is then the NDJSON stream, with the conversation object riding in message_start.data.conversation.)

Step 9 — createMessagePOST /conversations/con_01hzx8conv001/messages (platform JWT)

{ "content": "Summarize today's open jobs." }

200application/x-ndjson, one event per line, monotonic seq, terminated by exactly one message_end (or error):

{"object":"conversation.event","type":"queued","conversation_id":"con_01hzx8conv001","message_id":null,"seq":0,"data":{"position":2,"retry_hint_seconds":15},"created_at":"2026-07-02T10:00:00Z"}
{"object":"conversation.event","type":"message_start","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":1,"data":{"role":"assistant"},"created_at":"2026-07-02T10:00:01Z"}
{"object":"conversation.event","type":"content_delta","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":2,"data":{"text":"One moment while I pull that up — ","filler":true},"created_at":"2026-07-02T10:00:01Z"}
{"object":"conversation.event","type":"content_delta","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":3,"data":{"text":"You have three open jobs today."},"created_at":"2026-07-02T10:00:03Z"}
{"object":"conversation.event","type":"message_end","conversation_id":"con_01hzx8conv001","message_id":"msg_01hzx8asst001","seq":4,"data":{"message":{"object":"message","id":"msg_01hzx8asst001","...":"..."}},"created_at":"2026-07-02T10:00:04Z"}

The queued line appears only under on_capacity: "hold" when the pool is saturated; the "filler": true delta appears only when the filler cascade resolves to enabled — render it or suppress it, it is not part of the persisted message. Both the user message and the completed assistant reply land in history (listMessages) regardless of what happens to the connection. Full protocol: Streaming Contract.

6.3 Warm path — every request after the first

Section titled “6.3 Warm path — every request after the first”

Same code path; the branch happens on status codes, so there is nothing to remember between requests:

  1. upsertTenantByExternalId returns 200 (existed; provided fields merged — e.g. a name refresh from the host JWT) → skip steps 4–5 entirely.

  2. upsertUserByExternalId with the profile fields but without role_ids returns 200 — omitted fields are untouched, so role assignments survive:

    {
    "email": "jane.doe@acme.example.com",
    "display_name": "Jane Doe"
    }
  3. tokenExchange → fresh platform JWT (or a cached one still short of expires_at).

  4. Straight to conversations: listConversations?user_id=…, createMessage, etc.

The warm path costs two upserts and a token exchange — all idempotent, all safe to race. The full walkthrough (including the sequence diagrams, the crash-recovery matrix, and the double-provision race) is in Provisioning Flow.

A recurring integration need goes beyond skills-as-tools: before doing any real work, the agent should know things about the entity it is working for — the account’s history, open items, key figures — data that lives in the host’s external data systems (a warehouse such as Snowflake, a BI layer such as ThoughtSpot, an operational Postgres, a CRM). This section documents the recommended pattern. It is a pattern, not new API surface — it composes entirely from primitives this guide already covered.

The three moving parts:

  1. Vaulted credentials for the external data systems. Register each system’s credential once in the credential registry (createCredentialcrd_), exactly like the git token in §6.1 — or supply them conversation-scoped at run time via putConversationSecrets / message.secrets. Either way the zero-trust invariant holds: the agent only ever sees aliases; the egress proxy resolves them at the network boundary.
  2. A custom “context skill” — e.g. owner-context — added to the skills repository (or registered explicitly with createRepositorySkill, POST /repositories/{repository_id}/skills). Its script queries the external systems on demand, using aliased credentials, and emits a structured context brief.
  3. The agent loop runs it first. On a fresh conversation the agent executes the context skill before other work, so every subsequent step operates on gathered context. Because assistant turns persist to history (listMessages), conversation history carries the context forward — later turns in the same conversation do not re-gather unless staleness demands it.

Wired into the message flow:

  • The adapter (or host) can nudge deterministically — narrow the first message to the context skill with message.skill_ids: ["skl_…owner_context"], pass run parameters in env (e.g. {"ACCOUNT_REF": "…"}), and supply short-lived connection secrets in message.secrets.
  • If a required credential is missing, the agent raises a secret-kind approval (requested_items: [{"kind": "secret", "alias": "WAREHOUSE_PASSWORD", …}]) and the approver supplies it in the signed approveApproval body — the approval⇄vault weave from §4.7.
  • Re-gather timing — when the agent decides context is stale, what triggers a re-gather mid-conversation — lives inside the skill’s own instructions. Encode your refresh policy there (for example, “re-run this skill when the conversation references data older than the current shift”); the skill is versioned in your repository like any other capability.
DocumentWhat it covers
Provisioning FlowThe cold/warm walkthrough in full: sequence diagrams, merge-upsert semantics, race and crash-recovery behavior, reconciliation sweeps.
Streaming ContractThe NDJSON event protocol — every event type, ordering rules, truncation detection, and a client parsing recipe.
Adapter Implementation GuideHow to build your adapter: identity derivation, the zero-storage philosophy and cache policy, lifecycle reconciliation, HITL transport duties, ops.
Runtime ArchitectureThe composable runtime: skills + LLM harness control, pluggable agent types, sandbox-per-run security model, filler and capacity mechanics.
API ReferenceGenerated from the platform’s OpenAPI 3.1 specification — every operation, schema, and example.

The OpenAPI specification remains the single source of truth. When this guide and the spec disagree, the spec wins; file the discrepancy against the guide.