Guides
Profiles
What a session runs as — which engine, whose config directory, and which model.
A profile is what a session runs as. Most commonly it binds a name to a Claude Code config
directory: the spawned CLI process gets that directory as CLAUDE_CONFIG_DIR, so it loads the
directory’s settings, memory, skills — and resolves whatever credentials it holds. The canonical
use case is a shared machine where several team members each keep their own config dir:
createWorkerServer({
authenticate: async (req) => {
const user = await verifyMyAppToken(req.headers.authorization)
return user && { allowedProfiles: user.profiles } // e.g. ['ada']
},
profiles: [
{ name: 'ada', configDir: '/home/ada/.claude', defaults: { model: 'opus' } },
{ name: 'sam', configDir: '/home/sam/.claude' },
],
})
Engines
A profile also selects which engine runs the session. engine: 'claude' (the default, and
what everything above describes) is Claude Code via the Agent SDK. engine: 'codex' is OpenAI
Codex — the local codex binary driven over its app-server JSON-RPC surface — the direct
structural sibling: a local agent binary with sessions, sandboxing and resume, resolving its own
credentials from the operator’s environment (codex login in your own terminal; the optional
codexHome pins a CODEX_HOME the way configDir pins a config dir). engine: 'provider' runs
the model-agnostic engine — no config directory, no CLI process; the server builds it through
the createEngineRunner hook, which is where the operator resolves the model and its credentials.
profiles: [
{ name: 'ada', configDir: '/home/ada/.claude' },
{ name: 'codex', engine: 'codex' }, // the binary's own ~/.codex
{
name: 'kimi',
engine: 'provider',
// `apiKeyEnv` names the variable to read. It never holds a key, and no
// credential ever crosses the wire or lands in a profile response.
provider: { id: 'moonshotai', model: 'kimi-k3', models: ['kimi-k3'], apiKeyEnv: 'MOONSHOT_API_KEY' },
},
]
The engines answer to different vocabularies, and the server holds that line rather than
quietly coercing. Each engine declares an EngineCapabilities record — served on
ProfileInfo.capabilities and SessionInfo.capabilities, with the protocol’s
ENGINE_CAPABILITIES as the browser-safe default — and clients render around it instead of
switching on the engine name:
permissionModeis Claude Code’s vocabulary. A provider session runsdefault,bypassPermissionsanddontAsk; a codex session runsdefault,acceptEditsandbypassPermissions, mapped onto codex’s own sandbox (read-only / workspace-write / full access) plus its ask policy. Asking for a mode outside the record is a 400, and a profile whosedefaults.permissionModeis outside it failscreateWorkerServerat startup.- Codex approvals ride the same permission surface as Claude’s: the binary’s ask channels
(command escalations, file changes, permission grants, questions, MCP elicitations) arrive as
pending approvals, answerable from any client or over REST. One semantic difference, carried
in the request itself: a codex command approval is usually an escalation after the sandbox
already refused the command — the request’s title is codex’s own “command failed; retry
without sandbox?”, and approving re-runs the command unsandboxed. In
defaultmode a blocked action asks instead of silently failing;bypassPermissionsasks nothing. - The claude and codex engines ship a model catalog with each release, served as
ProfileInfo.modelsfrom the first request — a real picker on a cold server, with per-model reasoning efforts where the engine takes them. Provider engines have no equivalent of a pinned binary’s model table, so their list stays whatever the operator declared inprovider.models(falling back toprovider.modelalone). OnlydefaultModelis still learned from sessions — a claude profile’s default is the operator’s CLI config, unknowable statically. - With
checkCredentialson, the server probes each profile’s credentials (launch + ~60s TTL) and stampsavailable/unavailableReasononGET /profiles. Display-only: an unavailable profile is greyed with its remedy, but creating against it still proceeds and fails with the engine’s own error — a stale probe must never become an outage. SessionInfo.engineandSessionInfo.capabilitiesreport what a live session actually is, so a dashboard can hide affordances that don’t exist there. The bundled one does: resumable SDK sessions, setting sources, budgets, the questions policy and the bypass pre-authorization each appear exactly when the record declares them.
Session grants
A provider profile declares what its sessions get. createEngineRunner wires the backends
(a search implementation, a fetcher, MCP connections); the profile decides which of them a
session is actually granted, so withholding one is a profile edit rather than a code change:
{
name: 'kimi',
engine: 'provider',
provider: { id: 'moonshotai', model: 'kimi-k3', apiKeyEnv: 'MOONSHOT_API_KEY' },
session: {
capabilities: ['web_fetch', 'deliver_file'], // omit one and its tool is not built at all
mcpServers: ['deepwiki'], // names, resolved against what the host connected
instructions: 'You evaluate sales leads. …',
},
}
- Capabilities narrow, never widen.
POST /sessionsmay passcapabilitiesto run with fewer than the profile grants. Naming one it doesn’t grant is a 400 — refused rather than quietly downgraded, so a caller learns instead of wondering where the tool went. Omitting thesessionblock entirely means “no declaration”: the session gets whatever the host wired. - MCP servers are named here, never configured. A transport config’s headers can carry
credentials, and
ProfileInfois served byGET /profiles— so the configs (and the credentials) stay increateEngineRunner, and the profile only picks from what the host connected. One process-wide connection can serve a mixed fleet this way. - A provider session cannot bring its own MCP servers (400). MCP tools are authoritative —
they run server-side with server credentials and are never bridged to a browser — so honoring
a client-supplied server would let a caller point an authoritative tool anywhere. Claude
sessions may still pass
mcpServers: the CLI spawns those itself, under the operator’s own config directory.
Rules
- Profiles are declared at startup, and without a
profileStore(below) the API only reads them (GET /v1/profilesto list,GET /v1/profiles/:namefor a view-only config snapshot — settings.json highlights, memory, skills, agents, commands; env var names only, never values — shown on the dashboard’s profile detail page). A nonexistentconfigDirfailscreateWorkerServerfast — the CLI would otherwise silently start from an empty config. - With more than one profile declared, every
POST /sessionsandPOST /jobsmust name itsprofile(400 without one); with exactly one it is implicit. The resolved name always lands onSessionInfo.profileandJobInfo.profile, even when implicit. - No
profilesoption → adefaultprofile is auto-created from$CLAUDE_CONFIG_DIRor~/.claudewhen that directory exists, so single-operator deployments need no configuration. Pass[]to run without profiles (no env pinning at all). defaults(model,permissionMode) fill request fields the caller left unset — they are defaults, not enforced caps; an explicit request value wins.- Profile pinning composes with
buildRunnerConfig: the hook runs first, then the profile’sCLAUDE_CONFIG_DIRis applied on top — the profile wins even if the hook set its ownenv. The one exception: when the session env would already land the CLI in the profile’s directory (the auto-detecteddefaultprofile, typically), nothing is pinned at all — settingCLAUDE_CONFIG_DIReven to its default value changes where the CLI looks for credentials (see Credentials below).
Managing profiles from the dashboard
By default the profile set is startup config and the API only reads it. Pass a profileStore
to let the dashboard create, edit, and delete profiles too:
import { createFileProfileStore } from '@workerdeck/server'
createWorkerServer({
authenticate: async (req) => {
const user = await verifyMyAppToken(req.headers.authorization)
return user && { allowedProfiles: user.profiles, canManageProfiles: user.isAdmin }
},
profiles: [{ name: 'ada', configDir: '/home/ada/.claude' }], // still code, still immutable
profileStore: createFileProfileStore('/var/lib/workerdeck/profiles.json'),
allowedConfigDirRoots: ['/Users'], // omit to allow managed provider profiles only
})
- Doubly opt-in. The operator wires a store and the principal carries
canManageProfiles. Without a store the routes answer 404; without the flag, 403. - Declared profiles are code. They are never written to the store, can’t be edited or deleted
over the API (403), and win a name collision.
GET /profilesmarks store-backed onesmanaged: trueso a UI knows which rows it may touch, and reportscanManagefor the caller. - Same validation as startup. A profile created over HTTP goes through exactly the checks
createWorkerServerwould have applied, so the API can’t produce one the server would have refused to boot with. - No renames. The name is the route, not the body — sessions and jobs are already pinned to it.
- Managed Claude profiles need
allowedConfigDirRoots. A config directory is a credential store, so the server bounds which ones a managed profile may point at; unset, only provider profiles can be created. Startup-declared profiles are unaffected. createFileProfileStoreis single-process, exactly like the bundled queue adapter. Two servers sharing one file would race — that is what the seam is for.
Editing grants needs no extra ceiling: a profile can only ever grant capabilities and MCP servers
that createEngineRunner already wired, so the server’s own code bounds what any edit can reach.
Access control
The authenticate principal may carry allowedProfiles: string[]. The server enforces it on
session and job creation (403 otherwise) and filters GET /profiles to it, so pickers only
show what the caller may use. On a multi-operator machine this scoping is what keeps one worker
serving several people from degrading into account sharing — give each caller their own
profile(s) rather than a free choice. See
Auth & the providers’ terms for why that line matters.
Credentials
Profiles never touch the credential chain — they only select which config directory the
official CLI reads, via its own CLAUDE_CONFIG_DIR mechanism. Two consequences:
ANTHROPIC_API_KEYin the server’s environment wins for every profile (the SDK’s normal precedence). Per-session provenance stays visible asapiKeySourceonSessionInfo.- The subscription-credentials notice logs once per profile, and
requireApiKey: truefails closed regardless of profile. CLAUDE_CONFIG_DIRselects the credential source, not just the config dir. With the variable set, the CLI reads<dir>/.credentials.json; unset, it runs its own resolution — on macOS that is the login Keychain, whereclaude loginstores a claude.ai login. This is why the default profile is never pinned, and why a profile pointing at any other directory needs credentials of its own: runCLAUDE_CONFIG_DIR=<dir> claude auth loginthere, or inject a long-livedCLAUDE_CODE_OAUTH_TOKENviabuildRunnerConfig. The server’scheckCredentialsoption (on by default in theworkerdeckCLI) probes each profile withclaude auth statusat startup and warns — never fails — when a profile looks logged out. Only the logged-in/logged-out verdict is read; no credential or account material is touched.