Skip to content

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.

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 are kebab-case.
  • Every resource carries an object type 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": "..." } with limit, starting_after, and ending_before query 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.

SchemeTokenWho holds itUsed for
integrationKeysk_int_… service keyThe adapter (service principal)Everything: provisioning, registry management, tenant-wide reads, conversations, approvals transport
platformJwtShort-lived JWT from tokenExchangeA single end user’s request contextConversation 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 omit role_ids on 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 gets 200 with 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.

  • seq is a per-response monotonic counter starting at 0 with 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) or error (failure). A stream that closes without a terminal event MUST be treated as truncated; reconcile via listMessages.
  • Event types: message_start, content_delta, queued, approval_required, resumed, message_end, error.
  • content_delta events may carry data.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 for sticky_ttl_seconds (refreshed on every message; extendable or releasable via updateConversation). 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 in env — values are visible to the agent runtime verbatim.
  • secrets — write-only alias → value maps (on messages, on putConversationSecrets, 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.

SlugStatusMeaning
validation-error422Request body or parameters failed validation (errors[] lists pointers)
not-found404Resource does not exist (or is outside the key’s subtree)
name-conflict409Named sub-resource already exists (conflicting_resource_id)
external-id-conflict409external_id already taken via plain create (conflicting_resource_id)
cross-tenant409Referenced resource belongs to a different tenant
conversation-archived409Write attempted on an archived conversation
resource-in-use409Guarded delete refused; dependents exist (conflicting_resource_id)
role-required422User has multiple roles and no role_id was given
tenant-suspended403Tenant is suspended; conversation writes rejected
insufficient-scope403Key or token lacks the required scope
idempotency-key-conflict409Same Idempotency-Key, different request payload
approval-signature-invalid403Approval assertion signature failed verification
approval-expired409Approval already resolved or past expires_at
capacity-exhausted429No sandbox available (or max hold time exceeded); retry after Retry-After
rate-limited429Too many requests; retry after Retry-After

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

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