Sessions and identity
The canonical session context, the per-stream session ticket, and identity boundaries.
Neuralis carries caller identity in exactly one shape. Whether a request arrives from the web UI, a cross-package API call, a background job, or a skill script calling back into the host, the receiving code sees the same session vocabulary — and every new boundary reuses it instead of declaring a parallel "caller" type that restates the same fields.
SessionContext — the canonical identity
SessionContext (from @neuralis/package-system/contracts) is the shape
route handlers and tool handlers receive on every request:
type SessionContext = {
userId: string;
projectId: string;
agentId?: string;
conversationId?: string;
requestId?: string;
/** Runtime tool_use id — present only during model-initiated tool calls. */
toolUseId?: string;
/** Set on a subagent child's own tool calls: which delegate run they belong to and whether it is awaited or detached. */
delegateRunId?: string;
delegateMode?: 'foreground' | 'background';
/** User's role within the project (e.g. 'owner', 'admin', 'viewer'). */
role?: string;
/** Ordinal role priority — lower = stronger; undefined ⇒ system session. */
priority?: number;
/** Features granted to the user's role. ['*'] = all features. */
grantedFeatures?: string[];
/** USD spend limits (per-rule day|week|month windows) from project configuration. */
spendLimits?: { /* projectTotal, byRole, byUser, byAgent — each { amountUsd, period } | null */ };
/** Per-minute LLM request cap. Null/undefined ⇒ no rate limit. */
llmRateLimitRpm?: number | null;
/** Role's agent access: '*' = all, 'own' = owned/assigned, 'view' = read-only. */
agentAccess?: '*' | 'own' | 'view';
agentOwnership?: Record<string, { createdBy: string; assignedTo: string[] }>;
};The fields that carry authorization are priority, grantedFeatures, and
agentAccess — they feed the shared predicates described in
Features and access. role is a
cosmetic label, never an authority source: no gate reads the string, so a
custom role named anything behaves exactly as its declared priority and grants
say it should. The one place a role name still selects behaviour is the
byRole axis of a URI policy.
Session context is never sourced from tool input: every SessionContext
field name (plus the bundled session) is rejected as a top-level input
property of a tool schema. The ban is root-only by design — nested occurrences
are legitimate domain fields, and identity is always read from the verified
session, never from arguments (see Tools).
CrossPackageSession — package-to-package calls
Every getPackageApi() call carries a CrossPackageSession. Without one, the
target package's proxy denies the call — regardless of the caller's trust
tier.
| Field | Purpose |
|---|---|
userId, projectId | The scope the call operates in |
agentId? | Agent context, when the call is agent-scoped |
callerId | The calling package's id — for auditing and target-side policy |
callerTrust | 'first-party' | 'trusted' | 'untrusted' |
grantedFeatures? | Caller's grants, for target-side feature checks |
The called package uses this to scope operations, enforce its own feature gates and quotas, and audit who called on whose behalf.
System sessions and sentinel identities
First-party background work runs under a SystemSession: a
CrossPackageSession extended with isSystem: true and a systemReason. The
reason is a closed set of exactly three values — sync-scheduler,
mcp-api-key, and bootstrap — so the envelope can never be opened for an
unnamed purpose. It is a permission envelope, not an identity: only
first-party packages may construct one, via
createSystemSession(reason, callerId, scope?).
When the work targets a real project, the session carries the real
userId/projectId. When there is no project context at all, the fields hold
the sentinel constant '__system__' (SYSTEM_USER_ID, SYSTEM_PROJECT_ID,
SYSTEM_AGENT_ID). Sentinels are process-internal only and must never be
persisted or accepted from outside:
- Stores and path resolvers refuse to create files under a sentinel id.
- Every session built from external input (HTTP headers, MCP bearer tokens,
cookies) passes through
assertNotSentinel(), which throws a structuredSentinelIdentityErrorwhen any identity field carries a sentinel. - The external MCP boundary rejects sentinel-identity claims before dispatch.
A fourth synthetic constant exists alongside them: SKILL_USER_ID
('__skill__'), the audit principal recorded for a skill-originated call whose
real user is not yet resolved. It is deliberately not part of the sentinel
set the checks above share, because it is an audit label rather than a scope —
so it carries its own explicit refusal instead: a skill cannot mint a ticket
from another skill's synthetic identity.
User work never borrows system identity, and there is no way to smuggle a synthetic identity in from the outside.
The per-stream session ticket
Skill scripts run as child shell processes, yet their calls back into the
host's /api/packages/* routes must carry the real user identity — not a
service account. The session ticket is the single mechanism for this:
nrs1.<requestId>.<secret>- Mint. At every skill activation, the agent runtime mints a ticket from
the stream's live
SessionContext(SessionTokenMinter.mint(session)). The secret is a 32-byte random value pinned to the stream's scope; minting refuses sentinel and synthetic identities. - Inject. The ticket is projected into the script's environment as
NEURALIS_SESSION_TOKEN. The model never reads the value — it travels through the credential pipeline like any other secret. - Verify. The host's package route catch-all recognises the
nrs1.prefix, looks up the live stream scope byrequestId, and compares the secret in constant time. There is no signing key: the route handler and the stream orchestration share one process, so an opaque lookup is both simpler and instantly revocable. - Rebuild. Verification yields the original
SessionContext. The route derivesprojectIdandagentIdfrom it — never from caller-supplied parameters — and the package route dispatcher gates the request on the caller's real role and granted features, deny-by-default.
Ticket lifetime equals stream lifetime: when the stream ends and its scope is deleted, every ticket bound to it becomes invalid. Parallel calls within one stream are first-class (no single-use limitation), and the ticket is re-minted on every shell call, so multi-turn skill workflows keep a valid ticket across turns. Each verified call is audited as a skill session call.
One identity path
There is no platform-bearer or synthetic-owner shortcut for skills. Every internal skill-to-route call authenticates with the session ticket carrying the real session, and routes always gate on that real role and feature set. Packages should never accept identity fields from request bodies as a substitute.
Where sessions come from
- Host routes build the session from the authenticated web session and project membership, then pass it into the package route dispatcher — see Routes.
- Tool calls receive the session inside the tool-call context during the agent stream — see Lifecycle.
- Skill scripts use the session ticket above — see Skills.
- Tests construct sessions with the testing factories
(
createMockSession,createDeniedSession, …) — see Testing.