---
title: Gateway local sessions
description: Local CLI/TUI sessions as canonical gateway admissions
---

# Gateway-owned fresh local sessions

The composed gateway listener can create fresh local **CLI, TUI, or GUI-policy** sessions without
starting `tui_gateway`'s separate agent runtime. The existing `GatewayRunner` / `TurnRunner`
own execution, approval waiters, tool calls, session storage, and canonical event delivery.

A server-authenticated identity with `session:create` can call:

```json
{"jsonrpc":"2.0","id":1,"method":"session.create","params":{"request_id":"stable-client-create-id","source":"cli"}}
```

The result preserves `session_id`, `stored_session_id`, `messages`, `message_count`, and
`info`, and includes the authority's normal resume snapshot fields. Both session IDs are
the same stable persisted **logical** ID. Compression and reset may rotate the private
physical transcript target without changing either client ID. Creation is lazy with respect
to the agent, but reserves the real
SessionDB/SessionStore identity before returning. One canonical SQLite transaction commits
the session row, route, and private `gateway.local_policy.v1:<stored-id>` creation receipt.
Repeating the request ID for the same principal and profile reuses that identity and frozen
policy, including after daemon death. Omit the ID for a new session; clients that need
lost-ACK retry protection must retain it. A failed transaction publishes no live session.

Attach another authenticated viewer with `session.resume({session_id})`, and submit
through `prompt.submit({session_id, submission_id, text})`. The accepted receipt is durable
admission, not inference completion. Closing every viewer leaves the execution and its
pending approval alive. A new viewer can resume the same identity and answer through
`approval.respond({session_id, execution_generation, prompt_id, choice})`.

## Deliberate behavior change: shared operator access

Verified operators of the same gateway profile may attach to canonical local sessions
created through another operator authentication path. This replaces the creator-subject-only
access fence for those operators: the existing dashboard gate already grants operator access,
so changing from native CLI/TUI to an authenticated dashboard must not create a second runtime
or prevent attachment solely because the raw identities differ. This is shared access, not
shared identity: creation receipts keep the original owner, and admissions and retries keep
the submitting principal.

The server issues `session:operator` only at these existing trusted boundaries:

- Native interactive bootstrap ticket redemption (not worker-adoption tickets).
- The dashboard WebSocket upgrade gate, including authenticated dashboard login tickets,
  the process-lifetime internal credential for server-spawned PTY clients, and the verified
  legacy session token when that authentication path is enabled.
- The HTTP mutation gate, after a verified dashboard Session, native HTTP owner grant,
  or permitted legacy session token has been accepted.

A client cannot request this scope in RPC parameters or an identity capability list.
Operator scope does not replace profile, instance, per-operation capability, subscription,
revision, or generation checks. It does not upgrade messaging, room, worker, or service
credentials, or permit remote first-claim adoption of unowned historical transcripts. If
restricted dashboard identities are introduced, these issuance gates must reflect that
restriction rather than treating every accepted dashboard credential as an operator.

Cross-subject local submissions carry a private, server-written profile/session/principal
binding for queued execution and recovery. It is not exposed in model requests or public
session snapshots. This change does not expose the gateway listener remotely or implement
OpenAI API-to-local session affinity; those remain separate integration paths.

## Deliberately limited compatibility

- `source` accepts `cli` (default), `tui`, `gui`, `acp`, `tool` and `oneshot`
  (`gateway.session_policy.SURFACES`). They select the agent platforms `cli`, `tui`,
  `desktop`, `acp`, `cli` and `cli`; `tool` (third-party integrations) and `oneshot`
  (finite `chat -q` / `-z` runs) keep their own stored label so human pickers hide them.
  The native local routing identity remains server-owned `Platform.LOCAL`; source never
  grants messaging/native trust. Default TUI selection folds in `project`; GUI folds in
  the client-surface toolsets (`project`, `desktop_ui`, `catalog`). GUI policy is
  independent of `HERMES_DESKTOP` and of the attaching viewer's identity. Without explicit
  `toolsets`, the gateway applies the coding posture (`agent.coding_context: focus` in a
  code workspace, `agent/coding_context.py`) from the `cwd` a `session.create` names,
  decided once at creation; clients never send it as a pick. Only `session.create` applies
  it, never for `source: tool`: Kanban workers, cron jobs, A2A, hosted rooms and migrated
  legacy rows keep the profile's toolsets.
- Optional flat creation fields: `cwd` (existing absolute gateway-local directory),
  `model` (nonempty model identifier on the daemon's configured provider), and `toolsets`
  (explicit array of established toolset names, including an empty array). Unknown or
  policy-filtered toolsets reject instead of silently disappearing. Explicit toolsets
  override the default surface additions; CLI/TUI cannot explicitly request `desktop_ui`.
  `cwd`, model selection, source and effective toolsets are captured at creation; attach
  cannot change them. A conflicting repeat `request_id` returns `invalid_params`.
- Further frozen launch fields (`CREATE_FIELDS`): `provider`, `base_url` (http(s), no
  credentials/query/fragment), `api_key` (held in the owner's memory, never persisted),
  `reasoning`, `max_turns`, `skills` (rendered once into the frozen prompt; refused with a
  bypass launch), the booleans `yolo`, `checkpoints`, `accept_hooks`, `pass_session_id`,
  `ignore_rules`, `safe_mode`, `ignore_user_config` (safe mode implies ignore-user-config
  and runs turns in an isolated worker), `editor` (ACP only), plus `title` (resolve-or-create)
  and `hidden`. A malformed value returns `invalid_params`. Any other field, cwd worktree
  creation, seeded history, profile switching and service-tier selection still return
  `invalid_params`. No supported launch field mutates daemon-wide configuration or process
  environment.
- Fresh creation requires a server-authenticated identity with `session:create`, supplied
  by the gated dashboard or native bootstrap path. The verified legacy `?token=` route
  stamps a session-token identity and also supports creation. Callers cannot provide an
  identity in RPC parameters, and operator scope does not promote resume-only capabilities
  to `session:create`.
- `session.list({limit})` returns authorized **live** authority sessions (`scope: "live"`).
  The native TUI picker also requests `session.list({limit, scope: "stored"})` for pre-authority
  CLI/TUI/GUI history. This read-only feed uses the native legacy-resume ownership checks,
  excludes hidden/archived/internal and foreign-owned rows, and never adopts a session merely
  by listing it. Explicit resume performs adoption; the default live feed is unchanged.
  `session.info({session_id})` is a lightweight,
  authorized view of source, current model, lazy-agent status, and profile ID.
- `ping({})` returns `{pong: true}`. `runtime.describe({})` exposes only authority identity,
  epoch, capabilities, and the creation contract (`session_create.sources` and
  `session_create.parameters`, which clients check before sending a launch option). It does not claim full runtime
  protocol readiness, reveal credentials/paths, start an agent, or renew a turn lease.
- Cold restart restores valid private local policies and server-owned routes before execution.
  Creation receipts remain bound to their authenticated creating principal and profile;
  verified operators in that profile may access the canonical session without replacing
  that binding. Caller source/native context cannot reconstruct or authorize a local route.
  Missing, malformed, foreign-profile or mismatched policy/identity fails closed, including preclaim checks;
  historical pre-extension sessions without a receipt are not silently treated as CLI.
- Never-started queued local inputs resume through the same authority FIFO after bootstrap
  readiness. Interrupted started inputs become `unknown`, are never replayed, and pause
  their followers. Reattachment can inspect the unknown snapshot without running it.
- Compression publication atomically advances the receipt's private `entry.session_id` and
  route alongside the child transcript. The receipt retains its creation `session_id`, policy,
  principal/profile binding, and explicit approved `lineage`. Recovery validates compression
  steps with the existing canonical child selector and reset steps with the existing
  `_reset_from` boundary; it never adopts arbitrary forks or copied foreign receipts.
- The canonical SessionStore local reset commits the new row, old-row closure, receipt and
  route before changing the in-memory entry. Reset deliberately starts empty history while
  preserving the frozen launch policy and queued admission owner. It does not replay an
  interrupted execution or cancel later accepted inputs. Creating an independent conversation
  still uses `session.create` with a new request ID; this is not a new revision-fenced reset RPC
  or a change to the multi-view `/new` contract.
- Admission targets, digests, claim generations, subscriber identity and uncertain outcomes
  remain on the stable logical row. Thus lost-ACK retries keep their existing receipts after
  either transition, and shared controls/stream callbacks keep the same execution owner.
  Clients continue resuming the creation ID, not an internal physical continuation ID.

## Verification

`tests/gateway/test_operator_cross_surface.py` exercises native-created/dashboard-attached
and dashboard-created/native-attached sessions through real bootstrap and dashboard
login/callback/cookie/ticket paths in a disposable gateway process. The identity provider and
model endpoint are test fixtures. It verifies client disconnect/reconnect, FIFO admission and
retry, retained attribution and model context, unchanged system messages, and private
operator provenance absent from public responses and model requests. It does **not** kill
and restart the gateway process, directly compare simultaneous live fanout, or exercise
shared approval/clarify across those two authentication methods.

`tests/gateway/test_session_operator_scope.py` covers queued operator input across a fresh
authority epoch using a stub executor and a paused scheduler. This is unit-level recovery
evidence, not real cross-surface cold-process restart certification. The existing recovery
fixtures described below provide separate coverage; their results must not be presented as
a new end-to-end cross-authentication gateway-crash test.

`tests/gateway/test_local_session.py` launches a disposable process with temporary HOME
and HERMES_HOME, the production composed HTTP/WS listener, real single-use authenticated
tickets, a loopback OpenAI-compatible model, the real TurnRunner/AIAgent, and the real
terminal tool. It proves persisted identity and same-agent reattachment; closes both
viewers while approval is pending; reconnects and consents before deleting only an owned
temporary directory; checks canonical history; and rejects forged authentication/source/
profile and unsupported launch fields. A second case runs the real clarify tool,
reattaches after all viewers close, and verifies the answer reaches the next loopback
model request. No native messaging allow-all credential authorizes the execution; a
separate negative control enables messaging allow-all only while proving reconstructed
local source/profile objects still fail closed.

`tests/gateway/test_session_policy.py` adds three simultaneous CLI/TUI/GUI turns against
an owned loopback model. A barrier holds their first requests concurrently; real terminal
calls write separate files in three owned working directories. The next requests expose
the correct cwd results, distinct requested models and surface-specific tool schemas.
Reconnect preserves each agent; launcher environment carriers and config bytes do not
change. This is loopback integration evidence, not native launcher or vendor evidence.
`tests/gateway/test_local_session_recovery.py` proves rollback-before-publication and cold
receipt reuse, then starts separate ordinary daemons on the same temporary database using
real control-socket tickets and authenticated WebSockets. A fixture-only scheduler barrier
kills the first daemon after an input commits but before claim; another model request is
held after its real claim. Fresh daemons execute only the authorized queued input, preserve
history/source/model/toolsets despite changed defaults, and refuse unknown, foreign,
missing and corrupt policy cases. A further restart executes nothing twice. All inference
is loopback-only; this is not native launcher or compression-transfer evidence.

`tests/gateway/test_local_session_lineage.py` adds rollback and isolation invariants, plus
three separate ordinary daemon processes. Its first-process fixture invokes the production
agent rotation publication on the real cached AIAgent and the production SessionStore reset,
then kills the daemon after a durable queued admission. Cold unmodified daemons preserve
compressed history, reset's empty-history boundary, policy/cwd, admission identities and
unknown-state blocking. Unrelated fork work and foreign receipts stay unclaimed. The model
peer verifies recovered cwd in the actual prompt and frozen model/toolsets; this fixture does
not execute a post-restart cwd tool effect, generate a compression summary, or exercise a
native launcher. It proves the production publication/recovery boundary, not those separate
surfaces.
