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 (API Reference) 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 translation<br/>(native agent events → host shape)"]
  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 / pooled)"]
      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 translation — 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.Secrets at four scopes (§4.7), credential registry (§4.2), Runtime Architecture.
Fail-fast secret deliveryA run that references a secret vaulted at no scope terminates immediately with a missing-secret problem naming the alias — the host re-sends with the secret attached. No mid-run pause, no partial state.missing-secret (§4.7), Streaming Contract.
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.Repository & skill model (§5), Runtime Architecture.

Two bearer credentials exist. 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, secrets vaulting
Platform JWTShort-lived JWT (platformJwt scheme), obtained via tokenExchangeA single end user’s request contextConversation and message operations scoped to that one user

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 agent event stream
  A-->>H: translated reply

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, upsertConversationByExternalId, getConversationByExternalId✔ (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)
User secrets (putUserSecrets, listUserSecrets, deleteUserSecret)
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 and granted scopes:

{
"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"
]
}

Call it at startup to verify configuration and discover the root tenant — with zero local state.

Tenants, users, and conversations 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
Conversation{ns}:conversation:{host_conversation_id}acme:conversation:ticket-4521

The namespace {ns} is adapter configuration — one value per host system per environment (e.g. acme for production, acme-staging for staging). The conversation ID is whatever the host already uses to identify the thread — a ticket number, a chat-thread ID, a call ID — derived deterministically so the same host thread always produces the same external ID.

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
conversation.external_idone tenantper-tenant uniqueness constraint behind upsertConversationByExternalId

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/>ONE assigned repository"]
  ROL["Role rol_<br/>skill_access narrowing"]
  USR["User usr_<br/>external_id, role_id (one),<br/>storage bucket, secrets"]
  CON["Conversation con_<br/>external_id, context snapshot,<br/>runtime, storage bucket, secrets"]
  MSG["Message msg_<br/>env, secrets (ephemeral),<br/>skill narrowing"]

  REP -->|"assigned (one per tenant,<br/>inheritable from ancestors)"| TNT
  TNT --> ROL
  TNT --> USR
  ROL -->|assigned to| USR
  USR --> CON
  CON --> MSG

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_idle_ttl_seconds, max_concurrent_warm) — downstream scopes can only narrow within them. tenant.repository_id reflects the tenant’s single assigned repository (§4.3); it is read-only on the tenant resource and managed via PUT /tenants/{tenant_id}/repository.

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, the repository assignment, 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.

The credential registry is also the tenant scope of the four-scope secrets model (§4.7): a credential registered here is resolvable by every run in the tenant, always by alias, never by value.

4.3 Repository registry (rep_), skills (skl_), and the tenant assignment

Section titled “4.3 Repository registry (rep_), skills (skl_), and the tenant assignment”

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 assigned to any tenant
syncRepositoryPOST /repositories/{repository_id}/sync
listRepositorySkills / createRepositorySkillGET / POST /repositories/{repository_id}/skills

Repositories reach tenants through a single assignment — each tenant has exactly one:

OperationMethod & path
getTenantRepositoryGET /tenants/{tenant_id}/repository — the tenant’s effective assignment; when the tenant has none of its own, the nearest ancestor’s assignment is returned with inherited: true
assignTenantRepositoryPUT /tenants/{tenant_id}/repository — idempotent (201 first assignment, 200 merge/replace); body {repository_id, branch_override?}; branch_override reads a different branch of the same repository for this tenant only
unassignTenantRepositoryDELETE /tenants/{tenant_id}/repository — removes the tenant’s own assignment (it then inherits from an ancestor, or has none); 409 resource-in-use while active conversations still resolve to it with no ancestor fallback

The assignment is the whole repository story: every role, conversation, and message in the tenant resolves to this one repository. There are no repository overrides at the role, user, conversation, or message level — what varies below the tenant is only which of its skills a run may use (§5). Child tenants inherit the nearest ancestor’s assignment, so a deployment can assign once at the integration root and serve every tenant from the same capability tree.

A role is a tenant-scoped access profile with exactly one lever:

  • skill_access{"mode": "all"} or {"mode": "selected", "skill_ids": [...]}, narrowing within the tenant’s assigned repository (skill IDs must belong to it, else 422 validation-error).

A role’s resolved grant is answered by listRoleSkills (GET /roles/{role_id}/skills): the tenant 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. Roles carry metadata for host/adapter bookkeeping. Also: listRoles (GET /tenants/{tenant_id}/roles, ?name= exact filter as the recovery path), getRole / updateRole / deleteRole (GET/PATCH/DELETE /roles/{role_id} — delete is guarded while users hold the role, unless ?force=true).

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.

Each user holds at most one role — a nullable role_id. Omitting role_id on a warm-path refresh leaves the assignment untouched; providing it replaces it; null clears it. A user needs a role before starting conversations (422 role-required otherwise). A user’s effective skills are their role’s resolved grant within the tenant’s repository — answered with provenance (which role, which repository) by listUserSkills (GET /users/{user_id}/skills); empty when the user has no role.

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").

Users are also a secrets scope (§4.7): putUserSecrets vaults write-only alias → value pairs available to every conversation the user owns.

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}updateUser also changes/clears role_id
listUserSkillsGET /users/{user_id}/skills
putUserSecrets / listUserSecretsPUT / GET /users/{user_id}/secrets
deleteUserSecretDELETE /users/{user_id}/secrets/{alias}

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 change later. The user’s assigned role resolves the context; a user with no role is refused with 422 role-required. The repository is always the tenant’s (§4.3).
  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 and releases it) or warm (the conversation keeps its sandbox alive between messages under a sliding idle timeout, idle_ttl_seconds — reset on every message, extendable or releasable via updateConversation). A per-conversation storage bucket is auto-attached.

Conversations are the third natural key in the chain (tenant → user → conversation): a conversation may carry an external_id — the host’s own conversation identifier (§3.1), derived deterministically and unique per tenant. It keys the continue-vs-create decision: if no conversation with that external ID exists, create it; otherwise continue it. Two interaction styles serve it:

  • Deterministic upsertupsertConversationByExternalId (PUT /conversations/by-external-id/{external_id}): 201 created / 200 existed, the same merge semantics as tenants and users (provided → replaced, omitted → unchanged), and no 409 on the race — DB uniqueness is the lock. Pass initial_message to collapse continue-vs-create and the first message into one streamed round trip.
  • Guarded explicitgetConversationByExternalId (GET /conversations/by-external-id/{external_id}) to check, then createConversation with external_id to create — which responds 409 external-id-conflict if the ID is taken.

listConversations also accepts ?external_id= for exact-match lookup. Conversations created without an external ID (plain createConversation) simply carry external_id: null.

Messages are sent with createMessage (POST /conversations/{conversation_id}/messages); the default response is a streaming application/x-ndjson body — the platform’s native agent event stream: raw agent runtime events (deltas, complete turns with tool calls and results, the terminal result) interleaved with platform notice lines, versioned by the X-Shiftagent-Stream-Format response header. ?stream=false gives a blocking 201 JSON instead. The full protocol — line vocabulary, termination and truncation rules, the reference parsing recipe — lives in Streaming Contract. Each message carries per-run knobs:

  • skill_ids — narrow this run to specific skills so the agent context stays lean (within the conversation’s effective skills; the repository is always the tenant’s).
  • 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 and scoped to this message’s run (ephemeral — the most specific of the four secret scopes, §4.7); 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, most specific wins). Filler output is part of the reply and the persisted message — indistinguishable from agent output by design.
  • on_capacityreject (default): 429 capacity-exhausted + Retry-After when no sandbox is available; hold: the stream first emits queued platform notices 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, any warm sandbox is released, vaulted conversation secrets are destroyed.

4.7 Secrets at four scopes — and the missing-secret retry

Section titled “4.7 Secrets at four scopes — and the missing-secret retry”

Secrets are write-only alias → value maps, vaulted at the API boundary and never returned by any operation. The agent sees only aliases ({{secret:CRM_API_KEY}}); the egress proxy resolves aliases to real values at the network boundary on outbound calls. A rogue agent can exfiltrate nothing: it never holds real credentials.

Four scopes exist, resolved most specific first:

ScopeVaulted viaLifetimeTypical use
Messagesecrets on createMessageThis message’s run only (ephemeral)One-shot credentials the host injects per request
ConversationputConversationSecretsUntil the conversation is archived or its tenant deprovisionedCredentials staged for one thread’s work
UserputUserSecretsPersistent; available to every conversation the user ownsPer-user credentials (the user’s own CRM login, warehouse account)
Tenantthe credential registry (createCredential, §4.2)Operator-managedShared system credentials (git tokens, service accounts)
resolution: message → conversation → user → tenant

Missing secrets fail fast. A run that references an alias vaulted at no scope terminates immediately with a missing-secret problem naming the alias — as a terminal platform error line on the stream, or a 422 with ?stream=false. The recovery is a one-step retry: the host re-sends the message with the secret attached (in message.secrets, or staged first at a longer-lived scope). There is no mid-run pause and no partial state — the failed run’s messages are in history, and the re-sent message starts a fresh run that resumes the same session.

5. One repository per tenant, skills narrowed below it

Section titled “5. One repository per tenant, skills narrowed below it”

Which repository — and therefore which skills — an agent run gets is deliberately simple:

  • The repository is the tenant’s. Each tenant carries exactly one assigned repository (assignTenantRepository, §4.3). Child tenants without an assignment of their own inherit the nearest ancestor’s. Every role, conversation, and message in the tenant resolves to this single repository — there are no repository overrides below the tenant.
  • Everything below the tenant narrows skills. Roles, conversations, and messages each apply an intersection filter over the tenant repository’s skill catalog — each layer can only narrow, never widen:
effective_skills =
skills of the tenant's repository (what exists)
∩ role.skill_access (what the user's role permits)
∩ conversation.selected_skill_ids (what this thread opted into)
∩ message.skill_ids (what this turn narrowed to)

Setup (this mirrors the spec’s examples):

  • Registry: field-ops (rep_01hzx8fieldops, 5 skills, among them dispatch-scheduler and invoice-lookup).
  • Tenant Acme Field Services (tnt_01hzx8acme001) — field-ops assigned via PUT /tenants/tnt_01hzx8acme001/repository.
  • Role csr (rol_01hzx8csr001) — skill_access: {mode: "selected", skill_ids: [dispatch-scheduler, invoice-lookup]}.
  • Role dispatcherskill_access: {mode: "all"}.
  • User Jane — role csr. User Marco — role dispatcher.
#ScenarioEffective repositoryEffective skills
1Jane starts a plain conversationfield-ops (the tenant’s)csr’s 2 selected skills
2Marco starts a plain conversationfield-ops (the tenant’s)all 5 field-ops skills
3Jane’s conversation is created with selected_skill_ids: [skl_01hzx8invoice]field-opsthe one selected skill (⊆ csr’s grant)
4One message in Jane’s plain conversation carries skill_ids: [skl_01hzx8invoice]field-opsnarrowed to the one listed skill for this run only; the next message falls back to scenario 1
5A child tenant of Acme starts its first conversation with no assignment of its ownfield-ops, inherited: trueper its own roles

Scenario 4 is the per-run focus knob: a single turn runs with a minimal skill set — leaner context, smaller action surface — without touching any stored configuration. Scenario 5 shows the inheritance: capability trees are assigned once, high in the tenant hierarchy, and flow down.

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 and learn root_tenant_id. 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",
"repository_id": null,
"settings": {
"filler_enabled": true,
"default_agent_type": "claude-agent-sdk",
"max_idle_ttl_seconds": 3600,
"max_concurrent_warm": 5
},
"metadata": { "host_plan": "premium" },
"created_at": "2026-07-02T09:30:00Z",
"updated_at": "2026-07-02T09:30:00Z"
}

Step 4 — assignTenantRepositoryPUT /tenants/tnt_01hzx8acme001/repository

{ "repository_id": "rep_01hzx8fieldops" }

201 — the tenant’s single repository is assigned (the response embeds the full repository, now sync.state: "ready" with skill_count: 5):

{
"object": "tenant.repository",
"tenant_id": "tnt_01hzx8acme001",
"repository_id": "rep_01hzx8fieldops",
"branch_override": null,
"inherited": false,
"repository": { "object": "repository", "id": "rep_01hzx8fieldops", "...": "..." },
"created_at": "2026-07-02T09:34:00Z",
"updated_at": "2026-07-02T09:34:00Z"
}

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",
"skill_access": {
"mode": "selected",
"skill_ids": ["skl_01hzx8dispatch", "skl_01hzx8invoice"]
},
"metadata": {},
"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_id": "rol_01hzx8csr001"
}

201 — created, with the user’s single role set and 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_id": "rol_01hzx8csr001",
"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",
"external_id": "acme:conversation:ticket-4521",
"title": "Invoice questions",
"metadata": { "host_ref": "ticket-4521" }
}

The external_id is the third natural key (§3.1) — here derived from the host’s ticket number. On this guarded-explicit path a taken ID responds 409 external-id-conflict; the deterministic alternative is upsertConversationByExternalId (PUT /conversations/by-external-id/acme%3Aconversation%3Aticket-4521 with the same body minus external_id), which continues the existing conversation with 200 instead (§4.6).

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

{
"object": "conversation",
"id": "con_01hzx8conv001",
"external_id": "acme:conversation:ticket-4521",
"tenant_id": "tnt_01hzx8acme001",
"user_id": "usr_01hzx8jane001",
"title": "Invoice questions",
"status": "active",
"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",
"idle_ttl_seconds": null,
"sandbox_state": "cold",
"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 ID in the X-Shiftagent-Conversation-Id header and a leading platform.event line.)

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

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

200application/x-ndjson with X-Shiftagent-Stream-Format: claude-sdk/1: the native agent event stream, one JSON object per line, terminated by exactly one terminal line (the agent result event, or a platform error line):

{"object":"platform.event","type":"queued","position":2,"retry_hint_seconds":15}
{"type":"system","subtype":"init","session_id":"3f7c9a12-6c1e-4b8a-9f27-c11d2e83a001","tools":["dispatch-scheduler","invoice-lookup"]}
{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"You have three open jobs today."}}}
{"type":"assistant","message":{"content":[{"type":"text","text":"You have three open jobs today."}]}}
{"type":"result","subtype":"success","usage":{"input_tokens":1830,"output_tokens":12}}

The queued line appears only under on_capacity: "hold" when the pool is saturated. 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_id returns 200 — omitted fields are untouched, so the role assignment survives:

    {
    "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. The same continue-vs-create discipline applies here: upsertConversationByExternalId keyed on the host’s thread ID returns 200 and continues the existing conversation (or 201 if the thread is new) — no conversation mapping to remember, just like tenants and users. Then createMessage, listConversations?user_id=…, 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 dedicated API surface — it composes entirely from primitives this guide already covered.

The three moving parts:

  1. Vaulted credentials for the external data systems, at the right scope (§4.7). Shared system credentials go in the tenant credential registry (createCredentialcrd_), exactly like the git token in §6.1. Per-user credentials go in the user scope (putUserSecrets); thread-specific or short-lived ones ride conversation-scoped (putConversationSecrets) or per-message (message.secrets). At every scope 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 vaulted at no scope, the run fails fast with missing-secret naming the alias (§4.7) — the host re-sends the message with the secret attached, a one-step retry that keeps credential delivery need-to-know without any mid-run pause.
  • 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 agent event stream — line vocabulary, termination and truncation rules, and a client parsing recipe.
Adapter Implementation GuideHow to build your adapter: identity derivation, the zero-storage philosophy and cache policy, lifecycle reconciliation, stream translation duties, secrets handling, ops.
Runtime ArchitectureThe composable runtime: skills + LLM harness control, pluggable agent types, sandbox-per-run security model, warm sandboxes, 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.