Session portal
A safe mailbox between Claude Code and Codex sessions on this machine. One session
queues a short message for a specific session of the other product; the recipient drains
its inbox only when it is between turns, acknowledges, and decides what to do. The portal
is durable (SQLite), idempotent, and timid: it never edits a transcript, never wakes or
resumes a working session, and never executes the contents of a message.
monitor-agent-thread stays read-only. Monitoring and steering are separate capabilities.
What it is not
- Not a way to approve permissions, publish, merge, push, or delete on another session's
behalf. A message is untrusted data with a recorded author; the recipient acts under
its own gates.
- Not a transcript writer or a session resumer. It never writes a conversation log and never
pushes into or resumes a session — delivery happens only when the recipient itself pulls.
- Not a place where a caller asserts its own identity or authorization. Identity comes from
an authenticated token; sending steering or speaking as the user needs an operator grant.
- Not a network service. It binds nothing; the queue is a local user-level database.
Delivery model: pull-only
A message is marked delivered only when the authenticated recipient pulls it from its own
inbox — that pull is the only real proof the recipient received it. The portal never
fabricates a "delivered", "resumed", or "native-delivered" receipt it cannot back with a
pull. Sender-side push, auto-resume, and Codex-native delivery are not in this MVP;
portal_adapters.py only classifies whether a recipient looks safe to notify (advice, not
delivery).
Setup (authenticated MCP server)
The portal is a local stdio MCP server. Identity is bound at launch by a bearer token the
operator mints for each session; the server derives the session's identity from it, so a
session can never claim to be another.
Mint a token (operator, once per session) and copy it — it is shown only once:
python "{{SKILL_HOME}}/scripts/portal_admin.py" issue-principal --product claude --session <runtime-session-id> --label "what I'm doing"
Register the server with that product, passing the token in the environment (see
references/mcp-config.md):
SESSION_PORTAL_TOKEN=<token> python "{{SKILL_HOME}}/scripts/portal_mcp.py"
Health check (no token needed):
python "{{SKILL_HOME}}/scripts/portal_admin.py" health
Core workflow (MCP tools, acting AS the bound principal)
- Register/announce the current session (identity from the token):
portal_register_session {label}.
- Send to another session's inbox — source is your authenticated identity, not an
argument; idempotent with
idempotency_key:
portal_send_message {dest_session_id, body}.
- Receive — draining your OWN inbox is the pull that delivers:
portal_list_inbox {deliver: true}.
- Acknowledge what you acted on (as your bound identity):
portal_acknowledge {message_id}.
- Audit any message you are a party to:
portal_message_events {message_id}.
Tools: portal_list_sessions, portal_get_session, portal_send_message,
portal_list_inbox, portal_acknowledge, portal_cancel_message,
portal_get_message_status, plus portal_register_session, portal_message_events,
portal_health. The operator CLI (portal_admin.py) mints tokens, issues grants, and offers
health/recovery/uninstall.
Authorization: operator grants (not caller booleans)
Elevated actions are operator-issued, scoped, expiring capability grants — never a flag a
caller can set:
# let claude:B accept steering FROM codex:A for the next hour
python "{{SKILL_HOME}}/scripts/portal_admin.py" grant --to claude:B --capability accept-steering --scope codex:A --ttl 3600
Capabilities: send-steer (send a steering message to a scoped destination),
accept-steering (receive one from a scoped sender), speak-as-user (record
authorship=user).
Safety and privacy invariants
- Identity is authenticated, not asserted: source, inbox owner, and acknowledger are derived
from the bound token server-side.
- Message content is untrusted data; it is never executed and cannot bypass any product's
permission, publish, merge, push, or delete gate.
- Steering needs an operator
send-steer grant to send AND an accept-steering grant on the
destination to deliver; authorship=user needs a speak-as-user grant. No caller boolean.
- Prohibited content is rejected, never stored: hidden reasoning, raw tool arguments,
signatures, encrypted blobs, system/developer instructions, credentials, tokens,
environment values, and other secrets.
- Delivery is pull-only over the transport: a message becomes delivered only when its
authenticated recipient pulls it. Nothing is ever pushed into, or resumed for, another
session. (The operator CLI can force a drain by vouching for the boundary; the audit trail
records that as an operator attestation, not a recipient pull.)
- Loops and ping-pong are prevented by a forward-depth cap and a reversed-pair guard.
Documentation
references/architecture.md — components, storage, lifecycle, discovery.
references/threat-model.md — trust boundaries, prohibited data, non-capabilities.
references/mcp-config.md — per-product MCP registration.
references/troubleshooting.md — health, stale-lock recovery, uninstall, rollback.
1---2name: session-portal3description: Safe bidirectional message portal between Claude Code and Codex sessions. A durable local SQLite queue with an authenticated stdio MCP interface lets one session drop a short, authored message into another session's inbox; delivery is pull-only, so the recipient itself picks it up at a safe turn boundary, acknowledges, and acts under its own permissions. Identity is derived from a bearer token (not caller-asserted) and elevated actions need operator grants. Never writes transcripts, never pushes into or resumes another session, never runs message content. Use to hand a follow-up from Claude Code to Codex (or back) without manual relay. Monitoring stays separate and read-only (monitor-agent-thread).4---56# Session portal78A safe mailbox between Claude Code and Codex sessions on this machine. One session9queues a short message for a specific session of the other product; the recipient drains10its inbox only when it is between turns, acknowledges, and decides what to do. The portal11is durable (SQLite), idempotent, and timid: it never edits a transcript, never wakes or12resumes a working session, and never executes the contents of a message.1314`monitor-agent-thread` stays read-only. Monitoring and steering are separate capabilities.1516## What it is not1718- Not a way to approve permissions, publish, merge, push, or delete on another session's19 behalf. A message is untrusted **data** with a recorded author; the recipient acts under20 its own gates.21- Not a transcript writer or a session resumer. It never writes a conversation log and never22 pushes into or resumes a session — delivery happens only when the recipient itself pulls.23- Not a place where a caller asserts its own identity or authorization. Identity comes from24 an authenticated token; sending steering or speaking as the user needs an operator grant.25- Not a network service. It binds nothing; the queue is a local user-level database.2627## Delivery model: pull-only2829A message is marked **delivered only when the authenticated recipient pulls it** from its own30inbox — that pull is the only real proof the recipient received it. The portal never31fabricates a "delivered", "resumed", or "native-delivered" receipt it cannot back with a32pull. Sender-side push, auto-resume, and Codex-native delivery are **not** in this MVP;33`portal_adapters.py` only *classifies* whether a recipient looks safe to notify (advice, not34delivery).3536## Setup (authenticated MCP server)3738The portal is a local stdio MCP server. Identity is bound at launch by a bearer **token** the39operator mints for each session; the server derives the session's identity from it, so a40session can never claim to be another.41421. **Mint a token** (operator, once per session) and copy it — it is shown only once:4344 ```bash45 python "{{SKILL_HOME}}/scripts/portal_admin.py" issue-principal --product claude --session <runtime-session-id> --label "what I'm doing"46 ```47482. **Register the server** with that product, passing the token in the environment (see49 `references/mcp-config.md`):5051 ```bash52 SESSION_PORTAL_TOKEN=<token> python "{{SKILL_HOME}}/scripts/portal_mcp.py"53 ```5455Health check (no token needed):5657```bash58python "{{SKILL_HOME}}/scripts/portal_admin.py" health59```6061## Core workflow (MCP tools, acting AS the bound principal)62631. **Register/announce** the current session (identity from the token):64 `portal_register_session {label}`.652. **Send** to another session's inbox — source is your authenticated identity, not an66 argument; idempotent with `idempotency_key`:67 `portal_send_message {dest_session_id, body}`.683. **Receive** — draining your OWN inbox is the pull that delivers:69 `portal_list_inbox {deliver: true}`.704. **Acknowledge** what you acted on (as your bound identity):71 `portal_acknowledge {message_id}`.725. **Audit** any message you are a party to: `portal_message_events {message_id}`.7374Tools: `portal_list_sessions`, `portal_get_session`, `portal_send_message`,75`portal_list_inbox`, `portal_acknowledge`, `portal_cancel_message`,76`portal_get_message_status`, plus `portal_register_session`, `portal_message_events`,77`portal_health`. The operator CLI (`portal_admin.py`) mints tokens, issues grants, and offers78health/recovery/uninstall.7980## Authorization: operator grants (not caller booleans)8182Elevated actions are operator-issued, scoped, expiring capability **grants** — never a flag a83caller can set:8485```bash86# let claude:B accept steering FROM codex:A for the next hour87python "{{SKILL_HOME}}/scripts/portal_admin.py" grant --to claude:B --capability accept-steering --scope codex:A --ttl 360088```8990Capabilities: `send-steer` (send a steering message to a scoped destination),91`accept-steering` (receive one from a scoped sender), `speak-as-user` (record92`authorship=user`).9394## Safety and privacy invariants9596- Identity is authenticated, not asserted: source, inbox owner, and acknowledger are derived97 from the bound token server-side.98- Message content is untrusted data; it is never executed and cannot bypass any product's99 permission, publish, merge, push, or delete gate.100- Steering needs an operator `send-steer` grant to send AND an `accept-steering` grant on the101 destination to deliver; `authorship=user` needs a `speak-as-user` grant. No caller boolean.102- Prohibited content is rejected, never stored: hidden reasoning, raw tool arguments,103 signatures, encrypted blobs, system/developer instructions, credentials, tokens,104 environment values, and other secrets.105- Delivery is pull-only over the transport: a message becomes delivered only when its106 authenticated recipient pulls it. Nothing is ever pushed into, or resumed for, another107 session. (The operator CLI can force a drain by vouching for the boundary; the audit trail108 records that as an operator attestation, not a recipient pull.)109- Loops and ping-pong are prevented by a forward-depth cap and a reversed-pair guard.110111## Documentation112113- `references/architecture.md` — components, storage, lifecycle, discovery.114- `references/threat-model.md` — trust boundaries, prohibited data, non-capabilities.115- `references/mcp-config.md` — per-product MCP registration.116- `references/troubleshooting.md` — health, stale-lock recovery, uninstall, rollback.