Kortix as a Backend

Drive Kortix from your own backend on behalf of your end-users — each session brings that user's connectors, model, context, and secrets by reference.

Run one Kortix agent as the backend for many of your own end-users. Your service holds a single Kortix API key; every session it starts on a user's behalf brings that user's connectors, model, context, and secrets by reference. Your end-users never log in to Kortix — you are the tenant, they are yours.

If you have used Stripe Connect or a Twilio subaccount, this is the same shape: a platform account that acts for many downstream users, keeping each one's data and credentials isolated.

Everything here runs from your server with a normal API key. The only step that needs a browser is a user authorizing an OAuth app (e.g. Gmail) — and even that is a link you generate and hand to them.

1. Get an API key

Create a personal access token (kortix_pat_…) or a service account (kortix_sa_…) from your dashboard. Both authenticate your backend and both make a session's origin backend automatically. See Authentication and Accounts.

export KORTIX_API_URL="https://your-kortix-deployment.com/v1"   # API root, including /v1
export KORTIX_API_KEY="kortix_pat_…"
export KORTIX_PROJECT_ID="…"

2. Start a session on behalf of a user

A session-create call takes a set of backend overrides that scope the run to one of your end-users. All of them are optional; send only what you need.

Call create with the user's overrides

curl -X POST "$KORTIX_API_URL/projects/$KORTIX_PROJECT_ID/sessions" \
  -H "Authorization: Bearer $KORTIX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "end_user_ref": "your-app-user-123",
    "agent_name": "support",
    "opencode_model": "kortix/claude-opus-4.8",
    "connector_bindings": { "gmail": { "profile_id": "<profile-id>" } },
    "secrets": ["STRIPE_KEY"]
  }'

Or with the SDK

import { createScopedKortix } from '@kortix/sdk/server';

const kortix = createScopedKortix({
  backendUrl: process.env.KORTIX_API_URL,
  getToken: async () => process.env.KORTIX_API_KEY!,
});

const session = await kortix.project(projectId).sessions.create({
  end_user_ref: 'your-app-user-123',
  agent_name: 'support',
  opencode_model: 'kortix/claude-opus-4.8',
  connector_bindings: { gmail: { profile_id } },
  secrets: ['STRIPE_KEY'],
});

Use @kortix/sdk/server (createScopedKortix) when one process serves many users concurrently: each scoped client keeps its own token and never races another request's config.

The override fields

FieldWhat it does
end_user_refAn opaque id for your end-user. A label, not a lever: it tags the session, reaches the agent as KORTIX_END_USER_REF, and blocks an Idempotency-Key replay under a different end-user. It does not resolve that user's connectors or secrets, or grant access. It is recorded on every usage event and queryable — see Per-end-user cost. Formerly origin_ref, which is still accepted.
connector_bindingsMap a connector alias → a specific connection profile (your end-user's own connected account). Resolved server-side at use time; the credential never enters the sandbox.
inherit_unboundWith connector_bindings set, keep the project-default fallback for connectors you did not bind (instead of all-or-nothing). Only ever inherits the project default.
secretsAn allow-list of project secret names to expose to this session. Narrowing only — a session can never see a secret you did not list.
opencode_modelThe model this session runs, in provider/model form. Validated at create; an unservable model fails fast with 400 INVALID_SESSION_MODEL.
agent_nameWhich declared agent handles the session.
runtime_contextArbitrary non-secret key/values passed to the run as context.

Per-end-user cost

Every usage event carries the end_user_ref of the session that produced it, so you can bill your own users without keeping a parallel ledger.

# spend per end-user, biggest first
curl -sS "$KORTIX_API_URL/usage?group_by=end_user_ref" \
  -H "Authorization: Bearer $KORTIX_API_KEY"

# one end-user, totals AND breakdown narrowed
curl -sS "$KORTIX_API_URL/usage?end_user_ref=your-app-user-123" \
  -H "Authorization: Bearer $KORTIX_API_KEY"

Two things to know:

  • Spend with no end_user_ref — your own dashboard sessions, and anything predating this field — is excluded from the grouped breakdown but still counted in the top-level totals. Per-user rows will not sum to the account total.
  • Grouping is an exact match on the string you sent. user-1 and User-1 are two different end-users, so send a canonical id.

The dashboard shows the same data under Credits → Spend by end-user.

List one end-user's sessions

The same handle filters the session list, server-side:

curl -sS "$KORTIX_API_URL/projects/$PROJECT_ID/sessions?end_user_ref=your-app-user-123" \
  -H "Authorization: Bearer $KORTIX_API_KEY"
await kortix.project(projectId).sessions.list({ end_user_ref: 'your-app-user-123' });

Every status is included, so completed sessions come back too. The deprecated origin_ref spelling also works; sending both with different values is a 400.

3. Bring each user's own connector

Store a user's credential once through the connection-profile broker, get a profile_id, and pass it by reference in connector_bindings. Kortix resolves the credential server-side when the agent calls the connector — the secret never lands in the sandbox, and one binding can't reach another user's profile. See Connectors for the connector model.

Static connectors (mcp / http / openapi / graphql) take a credential you already hold, so the whole flow is headless:

const profile = await kortix.project(projectId).connectors.profiles.reconcile({
  connector_alias: 'user-mcp',
  owner_type: 'external',        // = your app's user (not a Kortix member/agent)
  owner_id: 'your-app-user-123',
  label: 'MCP for user 123',
});
await kortix.project(projectId).connectors.profiles.updateCredential(
  profile.profile_id, { value: usersOwnToken, kind: 'secret' });
await kortix.project(projectId).connectors.profiles.activate(profile.profile_id);
// → bind at create: connector_bindings: { 'user-mcp': { profile_id: profile.profile_id } }

OAuth apps (Gmail, Slack, Notion — provider: pipedream) have no static token; the user authorizes in their browser once. Still per-user and API-driven — only the consent click is interactive:

// 1. mint this user's own profile
const profile = await kortix.project(projectId).connectors.profiles.reconcile({
  connector_alias: 'gmail', owner_type: 'external',
  owner_id: 'your-app-user-123', label: 'Gmail for user 123',
});
// 2. get a connect link scoped to THIS user; send them to it
const { connectUrl } = await kortix.project(projectId)
  .connectors.profiles.pipedreamConnect(profile.profile_id, {
    success_redirect_uri: 'https://yourapp.com/connected',
    error_redirect_uri: 'https://yourapp.com/connect-failed',
  });
// → open connectUrl in the user's browser; they consent to Google/Slack/…
// 3. after they return, finalize (binds their authorized account)
await kortix.project(projectId).connectors.profiles.pipedreamFinalize(profile.profile_id);
// → bind by reference exactly like the static case

Never updateCredential an OAuth (pipedream) profile — the stored value is used as a Pipedream account id, not a raw OAuth token, so a pasted provider token silently fails on the first tool call. Use pipedreamConnect + pipedreamFinalize.

All-or-nothing binding. If a session's connector_bindings sets any alias, every unbound alias resolves to null for that session — bind every connector the agent needs in the one call, or pass inherit_unbound: true to keep the project-default fallback for the unbound aliases (so you can override just one connector without re-binding the rest; it only ever inherits the project default, never another user's profile). If a user disconnects mid-session (the profile is revoked), the connector fails closed rather than falling back to a shared account; prompt the user to reconnect.

4. Stream the answer

A session's runtime lives in a per-session sandbox that boots on first use. Await readiness, open the stream, then send the prompt:

const s = kortix.session(projectId, session.session_id);
await s.ensureReady();                 // boots/resumes the sandbox
const stream = s.stream({ onEvent: (e) => { /* render tokens/tools */ } });
await s.send("Summarize this user's new signups.");

The sandbox reaches your API over the network, so streaming works against a hosted deployment out of the box. For a local API, expose it with a public tunnel (e.g. cloudflared) — a cloud sandbox can't reach localhost.

A complete, runnable example — create connector → mint the per-user profile → start a backend session → stream — ships with the SDK at examples/09-kaab-backend-wrapper.ts.

Idempotent retries

Always send an Idempotency-Key (a UUID you generate once per logical create and reuse across that create's retries). A replay returns the same session instead of starting a second one. Replaying a key with a different body — different connector_bindings, secrets, end_user_ref, or runtime_context — is refused with 409 rather than silently reusing the first session.

Security model

  • Credentials never enter the sandbox. connector_bindings credentials are broker-resolved server-side at call time; secrets are injected as a narrowed allow-list. The agent's machine never holds an app secret.
  • Bindings can't cross tenants. A profile_id only resolves for the account and project that owns it; a wrong or foreign id is rejected as not-found.
  • end_user_ref is attribution, not authorization. It labels the session and is handed to the agent as KORTIX_END_USER_REF; it never grants access to a user's data on its own, and it resolves no connectors or secrets.
  • Personal connections stay private. A session that binds a member's personal connection is forced to visibility: private and resolves only for that user.

Errors you may hit

StatusCodeMeaning
400INVALID_SESSION_MODELopencode_model isn't servable for this account.
400INVALID_SESSION_CONNECTOR_BINDINGS / INVALID_SESSION_SECRETSMalformed overrides, or a secret/binding cap exceeded.
404CONNECTOR_PROFILE_NOT_FOUNDThe bound profile_id doesn't exist for this project (or isn't yours).
409CONNECTOR_PROFILE_INACTIVEThe bound profile is revoked or the connector is disabled.
409IDEMPOTENCY_*_CONFLICTAn Idempotency-Key was replayed with a different body. Keep the body identical across retries.
403CONNECTOR_NOT_ASSIGNEDThe session's agent isn't granted a connector you named. The first error most wrappers hit — list the alias under that agent's connectors: in your manifest.
403REQUIRE_CONNECTORS_INTERACTIVE_ONLYrequire_connectors is interactive-only. A backend key acts for no single person — bind an explicit profile_id with connector_bindings.
403origin_override_forbiddenA non-backend caller tried to set end_user_ref or secrets.
503AGENT_SWITCH_GRANT_UNAPPLIEDA mid-session agent switch was legal but its token re-mint could not be written. Retryable.
409CONNECTOR_CONNECTION_REQUIREDInteractive only: the user hasn't connected their own account. The body names which connector.
409END_USER_REF_CONFLICTYou sent end_user_ref and the deprecated origin_ref with different values. On session create it is a 409; on the session list filter it is a 400.
409IDEMPOTENCY_KEY_CONFLICTThe key collided with a different operation. Unlike the other idempotency errors, the fix is a higher-entropy key — the uniqueness index is global.
429concurrent_session_limit / per_origin_session_limitAccount-wide, or per-end-user when the operator enables it. Retry with backoff.
402subscription_required / insufficient_creditsBilling — not retryable without operator action.

What you can change once a session is running

OverrideMid-session
opencode_modelYes — PUT /projects/{id}/sessions/{sessionId}/model
agent_namePer message — each prompt names the agent that runs it
secrets, connector_bindings, runtime_context, end_user_refNo — set once at create

Changing the model

curl -sS -X PUT "$KORTIX_API_URL/projects/$PROJECT_ID/sessions/$SESSION_ID/model" \
  -H "Authorization: Bearer $KORTIX_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"opencode_model":"kortix/claude-opus-4.8"}'

The response carries applied_live. true means a running sandbox took it now; false means it applies at next start. Tell your user which — someone told "model changed" whose next answer comes from the old model has been misled. Applying it live restarts the runtime and ends the in-flight turn, so a no-op PUT deliberately does not restart.

Why secrets can't change

secrets_allowlist is written once. A mutable allowlist could be narrowed below what the sandbox already needs and leave the session unbootable. Start a new session to change what it may read.

Switching agents

Each message names the agent that runs it. A switch to an agent with a different secrets grant is refused 409 AGENT_SWITCH_REQUIRES_NEW_SESSION — and retrying cannot succeed, because re-scoping cannot un-read what the session already loaded. Offer a new session, not a retry.

A switch that changes only connectors or Kortix CLI actions is allowed and re-mints the session token: the new agent runs with exactly the grant its own manifest entry declares. Those gates check the token at call time, so re-pointing it works — unlike secrets, which are already in the box. If the re-mint cannot be written the prompt is refused (503 AGENT_SWITCH_GRANT_UNAPPLIED) rather than run under the previous agent's authority.

Isolation between your end-users

Every session your backend creates shares one created_by — your wrapper credential — so ownership alone cannot tell your users apart. The platform narrows on the caller's session instead: a token bound to end-user A's sandbox cannot read, share, or approve on end-user B's session.

Your backend credential is unaffected. It is the operator and still sees everything it created.

Inject end_user_ref server-side, from your authenticated session — never accept it from a browser. A client that can set it can bill another user, or replay their session through a shared Idempotency-Key.

Listing with ?scope=project includes rows the caller cannot open, marked can_access: false. Those are redacted — no metadata (which holds initial_prompt), no end_user_ref, no secrets_allowlist.

Kortix as a Backend – Kortix Docs