shiftagent Integration API
Overview
Host-system embedding surface for on-prem shiftagent — tenant/user provisioning by external ID, repository & skill registry, roles, conversations with streamed agent replies, HITL approvals, and conversation-scoped secret vaulting.
shiftagent Integration API 0.1.0
Section titled “shiftagent Integration API 0.1.0”The shiftagent Integration API is the contract between a host system’s adapter and an on-prem shiftagent deployment. The host system fronts all end-user traffic; a stateless, client-specific adapter derives identity from the host’s own JWT and calls this API. shiftagent’s database holds all mapping state — the adapter never needs storage of its own.
Host system ── host JWT ──▶ Adapter (stateless) ── sk_int_… key ──▶ shiftagent Integration API
Conventions
- JSON fields, query parameters, and enum values are
snake_case; URL segments arekebab-case. - Every resource carries an
objecttype discriminator and a prefixed ID (tnt_,usr_,rol_,rep_,skl_,crd_,con_,msg_,apr_). - Lists are cursor-paginated:
{ "object": "list", "data": [...], "has_more": true|false, "next_cursor": "..." }withlimit,starting_after, andending_beforequery parameters. - Errors are RFC 9457
application/problem+json(see the problem type registry below). - Timestamps are ISO 8601 / RFC 3339, always UTC.
Authentication
Two bearer credentials exist; every operation documents which it accepts.
| Scheme | Token | Who holds it | Used for |
|---|---|---|---|
integrationKey | sk_int_… service key | The adapter (service principal) | Everything: provisioning, registry management, tenant-wide reads, conversations, approvals transport |
platformJwt | Short-lived JWT from tokenExchange | A single end user’s request context | Conversation and message operations scoped to that user |
The integration key is role-mode (no user directory behind it) and is
scoped to the integration’s root tenant — every tenant it provisions is
a child of that root, and the key can never see outside its subtree. Use
getIntegrationSelf (GET /integration/self) to introspect the key.
For per-user calls the adapter exchanges external IDs for a short-lived
platform JWT via tokenExchange (POST /auth/token-exchange) and
forwards requests under that token. There is no acting-as header: user
context is always carried by the token, tenant-scope calls run under the
service key directly.
getHealth (GET /health) is the only unauthenticated operation.
External ID namespacing
Tenants and users are addressed by the host system’s identifiers via
by-external-id subresources. External IDs are opaque to shiftagent:
compared byte-exact after trimming, max 255 characters, case-sensitive.
Adapters MUST namespace them at derivation so multiple host systems (or
environments) never collide:
- tenants —
{ns}:tenant:{host_tenant_id}(e.g.acme:tenant:128231) - users —
{ns}:user:{host_user_id}(e.g.acme:user:9f27c1)
The namespace {ns} is adapter configuration. The adapter owns
canonicalization (e.g. lowercasing GUIDs) — shiftagent never normalizes.
Upsert semantics (PUT …/by-external-id/{external_id})
The provisioning primitive is an idempotent merge-upsert:
201— the resource did not exist and was created. An empty body{}is valid: external ID–only creation is guaranteed to succeed, all other fields are enrichable later.200— the resource existed; provided fields were merged.- Per-field merge rules: provided → replaced; omitted → unchanged;
explicit
null→ cleared (nullable fields only). Adapters that omitrole_idson the warm path therefore never wipe role assignments. - Concurrent upserts of the same external ID are resolved by a uniqueness
constraint: the winner gets
201, the loser gets200with the winner’s record. Neither errors — there is no 409 on this path.
409 conflicts with a conflicting_resource_id extension apply only to
named sub-resources (repositories, roles, skills, credentials), where
they make crashed provisioning runs recoverable: fetch the conflicting
resource by ID and continue.
Idempotency
All POST operations accept an optional Idempotency-Key header.
Responses are cached for 24 hours per (key principal, operation,
idempotency key); a replay returns the original status and body with
Idempotency-Replayed: true. The same key with a different request payload
yields 409 idempotency-key-conflict. PUT upserts and DELETEs are
idempotent by construction and do not need the header.
Streaming (NDJSON)
createMessage (and createConversation with initial_message) responds
with application/x-ndjson: one ConversationEvent JSON object per line.
seqis a per-response monotonic counter starting at0with no gaps — clients detect truncation by gaps or by a missing terminal event.- Every complete stream ends with exactly one terminal event:
message_end(success) orerror(failure). A stream that closes without a terminal event MUST be treated as truncated; reconcile vialistMessages. - Event types:
message_start,content_delta,queued,approval_required,resumed,message_end,error. content_deltaevents may carrydata.filler: true— low-latency filler output that hosts may render or suppress; filler text is not part of the persisted assistant message.
Runtime, stickiness & capacity
Each conversation runs on a sandboxed agent runtime, selected by
runtime.agent_type (open enum — e.g. claude-agent-sdk, codex,
deepagent; defaults from tenant settings). Two placement modes:
pooled(default) — each message claims a warm-pool sandbox.sticky— a dedicated sandbox is leased to the conversation forsticky_ttl_seconds(refreshed on every message; extendable or releasable viaupdateConversation). Tenant settings cap the max TTL and max concurrent sticky sandboxes.
When the pool is exhausted, the caller-chosen on_capacity strategy
applies: reject (default) → 429 capacity-exhausted with
Retry-After; hold → the request is held and the stream first emits
queued events (data.position, data.retry_hint_seconds) until a
sandbox frees, bounded by a deployment-configured maximum hold time (then
capacity-exhausted). getCapacity exposes live pool state for
pre-checks.
Human-in-the-loop approvals
Mid-run, the agent may raise an Approval (apr_): the stream emits
approval_required carrying the Approval object (including
requested_items — what the agent needs and why) and the message parks in
awaiting_approval. Resolution requires a signed assertion over
{approval_id, decision, exp} using a per-tenant approver key
registered out-of-band — cryptographically distinct from the sk_int_
service key. The adapter can transport an approval but can never mint
one (two-party control). On approve the run emits resumed and continues
to message_end; deny/expiry ends the message failed with a
problem-typed error event.
Env vars & secrets
env— plaintext, non-secret run parameters. Never put secret material inenv— values are visible to the agent runtime verbatim.secrets— write-onlyalias → valuemaps (on messages, onputConversationSecrets, and on approval resolutions). Values are vaulted at the API boundary, scoped to the conversation, and never appear in any response or in the agent’s context. The agent sees only aliases (e.g.{{secret:CRM_API_KEY}}); the egress proxy resolves aliases to real values at the network boundary on outbound calls. Combined with approvals, even a rogue agent can exfiltrate nothing: it never holds real credentials and cannot self-approve.
Problem type registry
Errors use RFC 9457 application/problem+json. type URIs live under
https://shiftagent.example.com/problems/{slug} (illustrative host — each
deployment substitutes its own). Every problem carries request_id;
conflict problems add conflicting_resource_id where applicable.
| Slug | Status | Meaning |
|---|---|---|
validation-error | 422 | Request body or parameters failed validation (errors[] lists pointers) |
not-found | 404 | Resource does not exist (or is outside the key’s subtree) |
name-conflict | 409 | Named sub-resource already exists (conflicting_resource_id) |
external-id-conflict | 409 | external_id already taken via plain create (conflicting_resource_id) |
cross-tenant | 409 | Referenced resource belongs to a different tenant |
conversation-archived | 409 | Write attempted on an archived conversation |
resource-in-use | 409 | Guarded delete refused; dependents exist (conflicting_resource_id) |
role-required | 422 | User has multiple roles and no role_id was given |
tenant-suspended | 403 | Tenant is suspended; conversation writes rejected |
insufficient-scope | 403 | Key or token lacks the required scope |
idempotency-key-conflict | 409 | Same Idempotency-Key, different request payload |
approval-signature-invalid | 403 | Approval assertion signature failed verification |
approval-expired | 409 | Approval already resolved or past expires_at |
capacity-exhausted | 429 | No sandbox available (or max hold time exceeded); retry after Retry-After |
rate-limited | 429 | Too many requests; retry after Retry-After |
Authentication
Section titled “ Authentication ”integrationKey
Section titled “integrationKey ”The adapter’s service-principal integration key (Authorization: Bearer sk_int_…). Role-mode (no user directory behind it), scoped to the integration’s root tenant subtree. Grants the coarse scopes listed by getIntegrationSelf. Accepted by every authenticated operation.
Security scheme type: http
Bearer format: sk_int_… service key
platformJwt
Section titled “platformJwt ”Short-lived platform JWT minted by tokenExchange, carrying a single user’s identity. Accepted only by conversation and message operations, scoped to that user. Default TTL 15 minutes.
Security scheme type: http
Bearer format: JWT