Guides
Profiles
What a session runs as — a named Claude Code config directory, or a model provider for the model-agnostic engine.
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: 'provider' runs the
model-agnostic engine instead — 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: '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 two engines answer to different vocabularies, and the server holds that line rather than quietly coercing:
permissionModeis Claude Code’s vocabulary. A provider session runsdefault,bypassPermissionsanddontAsk; asking foracceptEdits,planorautounder a provider profile is a 400, and a provider profile whosedefaults.permissionModeis one of those failscreateWorkerServerat startup.- Provider engines have no equivalent of the CLI’s
supportedModels(), so the model list is whatever the operator declared inprovider.models(falling back toprovider.modelalone). SessionInfo.enginereports which engine a live session is actually on, so a dashboard can hide affordances that only exist on one side. The bundled one does: resumable SDK sessions, setting sources, and the bypass pre-authorization disappear for provider profiles.
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.
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 '@claude-worker/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/claude-worker/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 & Anthropic’s 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.