Skip to content

Push Architecture

Push is one local Rust process. It receives from configured iMessage, Telegram, and Slack channels, filters messages, loads the configured assistant repository, runs a configured agent backend, and sends the final reply.

The important boundary is not iMessage or Claude. The important boundary is:

message gateway -> agent backend -> message gateway

Ownership is split deliberately:

Push runtime         = channels, scheduling, history, security, delivery
Assistant repository = SOUL.md, context, jobs, optional project skills
Agent runtime        = reasoning, tool and skill execution, permissions,
                       global skills, MCP, authentication

Principles

1. Gateway First

Push is a messaging gateway for a personal assistant. It should stay small and own the durable pieces:

  • channels
  • allowlists
  • routing
  • assistant repository location and runtime instruction composition
  • canonical conversation history
  • validated runbook jobs and their durable run ledger
  • cursor and backend-session state
  • delivery

2. Runtime Disposable

Agent runtimes are replaceable. Claude Code, Codex, and Pi are the current adapters. More can be added without changing the messaging core.

The gateway should not build:

  • its own agent loop
  • its own plugin system
  • its own MCP layer
  • its own coding workflow
  • its own tool runner

Those belong to the selected backend.

3. Outbound connections only

Push polls channel adapters and shells out to local agent commands. It opens no server port and accepts no inbound network connection. Telegram uses outbound HTTPS long polling and Slack uses an outbound Socket Mode WebSocket.

The trust boundary is the messaging account plus the configured channel allowlist.

System Overview

flowchart LR
    user([You]) -->|iMessage| db[(chat.db)]
    user -->|private chat| tg[Telegram Bot API]
    user -->|app DM| slack[Slack]
    db -->|poll| push
    tg -->|long poll| push
    slack -->|Socket Mode| push
    subgraph push[Push gateway]
        poller[Channel poller] --> gateway[Gateway loop]
        gateway --> worker[Per-thread worker]
        store[(state.json)] <--> gateway
        history[(push.db)] <--> gateway
        assistant[/assistant repo: SOUL.md, context, jobs, project skills/] --> worker
        worker --> adapter[Agent adapter]
    end
    adapter -->|claude -p| claude[Claude Code]
    adapter -->|codex exec| codex[Codex]
    adapter -->|pi --print| pi[Pi]
    claude --> adapter
    codex --> adapter
    pi --> adapter
    adapter --> sender[Sender]
    sender -->|osascript| db
    sender -->|sendMessage| tg
    sender -->|chat.postMessage| slack
    db -->|reply| user
    tg -->|reply| user
    slack -->|reply| user

Channel Boundary

The gateway depends on one closed, compile-time channel contract. iMessage, Telegram, and Slack implement it, and the Channel enum provides static dispatch. This is an internal Rust boundary, not a dynamic plugin system or a configuration extension point.

Each channel implementation owns these semantics:

  • Inbound polling: read events after an opaque monotonic cursor and report the latest cursor used to skip backlog on first start. A pending poll must be cancellation-safe because shutdown drops its future.
  • Authorization: reject unsupported, group, empty, looped-back, or non-allowlisted input according to provider rules before backend dispatch.
  • Thread identity: return a stable channel-qualified thread key, the exact reply target, approval sender/chat identity, and ordered route-key groups. Telegram topics inherit a parent-chat route; iMessage retains its legacy unprefixed route aliases. Slack keeps one workspace-scoped DM identity while targeting the exact originating Slack message thread.
  • Outbound delivery: validate proactive targets, plan durable chunks, and send one chunk to the exact accepted target. Replies never cross from one channel loop to another.
  • Typing: declare an optional refresh interval and send best-effort activity updates. Typing failures do not fail an assistant turn.
  • Rich messages: choose plain or rich delivery per chunk. iMessage keeps one plain marked reply. Telegram and Slack own their formatting, size limits, splitting, and provider-specific addressing.
  • Retry: expose the timeout and bounded retry cadence used for stored chat replies. A send call is one attempt. Generated output is persisted before delivery and retried without rerunning the backend. Scheduled delivery keeps its separate durable attempt ledger and resumes at the first unsent chunk.
  • Shutdown: stop polling when its future is cancelled, drop queue senders, drain accepted per-thread work for the contract's grace period, then abort any remaining workers. Transport futures must release their resources when dropped.

Adding another built-in channel therefore requires a concrete contract implementation and one enum variant. It does not require channel-name branches in the shared polling, routing, worker, retry, or shutdown loops.

Message Lifecycle

sequenceDiagram
    participant U as User
    participant C as iMessage or Telegram
    participant P as Poller
    participant G as Gateway
    participant W as Worker
    participant H as push.db
    participant A as Agent backend
    participant S as Sender

    U->>C: send message
    P->>C: poll after channel cursor
    C-->>P: messages
    P->>G: Message values
    G->>G: apply channel allowlist and filters
    G->>H: persist accepted inbound message
    alt slash command
        G->>S: handle command locally
    else assistant turn
        G->>W: enqueue job by thread
        W->>W: load SOUL.md and append resolved assistant paths
        W->>W: resolve routed backend session
        opt fresh backend session
            W->>H: read bounded recent conversation
        end
        W->>A: run new message or rehydrated prompt
        A-->>W: reply and optional backend session id
        W->>H: persist generated outbound message
        W->>S: send reply
        W->>G: ack completed row
    end
    S->>C: send through channel adapter
    C->>U: delivered reply

Backend Boundary

The gateway calls an agent through this internal shape:

Request {
    session_id,
    is_new,
    work_dir,
    instructions,
    prompt,
}

RunOutput {
    reply,
    session_id,
}

That keeps the gateway independent of backend-specific mechanics.

Normal resumed turns contain only the new request. Fresh sessions use at most 20 prior messages from the exact channel-qualified conversation. Push caps each historical message at 4 KiB and the history block at 16 KiB, then JSON-delimits roles and content before appending the current user message. This transcript is prompt content; SOUL.md remains separate instruction context. A recognized missing-session error rotates the stored backend session and retries once with rehydration. Audit metadata records the rehydrated message count.

Claude Code Adapter

Claude Code lets Push choose the session id.

  • New conversation: claude -p --session-id <uuid>
  • Existing conversation: claude -p --resume <uuid>
  • Instructions: --append-system-prompt <SOUL.md + resolved assistant paths + gateway policy>
  • Work dir: assistant_root

Codex Adapter

Codex creates its own thread id.

  • New conversation: codex exec --json ...
  • Existing conversation: codex exec resume <thread_id> ...
  • Instructions: -c developer_instructions=<SOUL.md + resolved assistant paths + gateway policy>
  • Work dir: assistant_root, including resumed runs

The adapter reads Codex JSONL events to capture thread.started.thread_id and stores that id for future turns.

Pi Adapter

Pi creates its own session id and reports it in the first JSON event.

  • New conversation: pi --print --mode json ...
  • Existing conversation: pi --print --mode json --session <session_id> ...
  • Instructions: --append-system-prompt <SOUL.md + resolved assistant paths + gateway policy>
  • Work dir: assistant_root

The adapter reads the session header and final assistant message_end event. Push passes no tool override to Pi. Pi has no native filesystem sandbox or interactive permission prompts, so its own configuration is the boundary.

State Model

state.json stores channel-specific cursors and channel-qualified sessions:

{
  "last_row_id": 123,
  "cursors": {
    "imessage": 123,
    "telegram": 456,
    "slack": 789
  },
  "sessions": {
    "imessage:self:you@icloud.com": {
      "uuid": "backend-session-id",
      "started": true,
      "backend": "codex"
    }
  }
}

last_row_id remains for compatibility with old iMessage state files. The field named uuid also remains for compatibility, but it now means "backend session id".

If the configured backend changes for a thread, Push starts a fresh backend session instead of trying to resume the old runtime's session.

Slack's dedicated receiver commits accepted Socket Mode events to <state_path>.slack-inbox.db before acknowledging their envelopes. It continues receiving while the gateway processes earlier events. The inbox assigns monotonic cursor rows and deduplicates Slack's stable event_id, so accepted input survives a process restart without relying on an in-memory WebSocket buffer. Ignored envelopes retain redacted rejection metadata for the audit path without retaining message content.

With advanced channels = ["imessage", "telegram", "slack"] configuration, one coordinator starts an independent polling loop, acknowledgement tracker, and thread queue map for each enabled provider. The loops share one locked state store, canonical history database, backend runner set, and serialized audit log. One provider can fail or rate-limit without cancelling the other. A shared shutdown signal cancels pending polls and lets every channel drain its workers. Replies remain bound to the originating loop and exact target.

Voice Boundary

Voice has two independent adapters. A channel adapter exposes opaque inbound voice metadata, downloads bytes only after the normal allowlist accepts the message, and uploads a generated audio clip. The provider-neutral voice layer transcribes and synthesizes in-memory audio. Its output is plain text or a generic audio clip, so it has no Telegram identifiers or Bot API behavior. Download and transcription run in the accepted message's per-thread worker, so slow audio cannot pause polling or work in another conversation.

The first provider uses voice.openai_api_key, with OPENAI_API_KEY as a higher-priority environment override, for both gpt-4o-transcribe and gpt-4o-mini-tts. Speech uses the configured voice.name, which defaults to cedar. The first channel implementation is Telegram. A future channel needs only voice download and upload support. It does not need to change gateway routing, agent requests, or the OpenAI client.

Voice is an optional enhancement. Missing credentials stop voice processing for that message with a text explanation, while ordinary text traffic remains available. Speech synthesis and voice delivery happen after durable text delivery, so a voice-side failure cannot discard an agent answer.

Optional primary_delivery selects one enabled, allowlisted channel target for proactive output. Resolution is lazy: an absent or invalid primary returns a scoped error to the proactive caller without affecting reply polling.

When primary delivery resolves, the gateway also runs the cron scheduler. It evaluates validated five-field triggers against their IANA timezone, enqueues at most one occurrence after an in-process clock jump, and initializes future-only occurrences after restart so downtime is never caught up. A configured worker limit bounds fresh-session job execution. Scheduled and manual processes share the per-job advisory lock and SQLite active-run uniqueness boundary.

Scheduled output or bounded failure detail is committed before notification. Delivery has its own persisted state and is retried up to five times from that stored result. Restart recovery resumes queued work and pending delivery, but never reruns a backend run that had already started. A running row is marked interrupted only after the released advisory lock proves its executor is gone.

push.db stores channel-qualified conversations and their inbound and outbound messages. Accepted inbound messages are inserted before gateway commands or backend dispatch. Generated backend, command, and error replies are inserted before delivery, with generation and delivery state tracked separately. A unique channel event ID makes inbound retries idempotent, and a unique link from each inbound message to its outbound response preserves the generation/delivery crash boundary. SQLite history does not replace state.json cursors or backend session IDs in this phase.

The same database stores immutable job-run claims and bounded terminal results. Markdown runbooks live under <assistant_root>/jobs; their write boundary is set by the selected agent's configuration. A manual CLI start rereads and validates the exact file, acquires a non-blocking per-job lock, then records and claims the run in one immediate SQLite transaction before spawning a fresh backend session. The CLI holds the lock through result persistence. Only a process that acquired the released lock may fail a stale manual claim, so a live local executor is never reclaimed from ledger state alone. Manual runs do not reuse chat history or backend session ids.

The same database stores ask_user questions before delivery. Each question has a UUID correlation id, two to nine bounded choices, an expiry, delivery state, and an exact channel, sender, chat, and thread/topic binding. Inbound numbered replies pass the normal allowlist first, then resolve transactionally to one normalized value. The workflow consumes an answer at most once. Pending questions survive restart; cancellation and expiry are terminal, and rejected or duplicate answer attempts are audited without reaching a backend session or the rehydrated conversation transcript. Workflows can poll the durable question state and consume an answered value once; crossing the expiry first records an expired terminal state, so later cancellation cannot overwrite the timeout.

Agent-created jobs live directly under <assistant_root>/jobs. The gateway includes that absolute path in its in-memory instructions and tells the agent to run push job validate after a change. Job creation has no separate draft or approval step. The selected agent's filesystem permissions control whether it can change the assistant repository.

audit_log_path stores a local JSONL event stream for production debugging. Audit events record message metadata, routing decisions, approval outcomes, backend run starts and failures, reply delivery metadata, and row completion. Message and reply text are redacted by default; audit_log_content opts into content logging.

Assistant Repository

Push supports one assistant and stores one canonical assistant_root. It derives SOUL.md, context/, and jobs/ from that root. push init [path] creates the conventional structure, initializes Git when needed, and persists the root through the selected --config file. There are no assistant IDs, registries, active selections, or multi-assistant commands.

For every conversation and scheduled or manual job run, Push reads SOUL.md and appends a gateway-owned footer in memory containing the resolved absolute assistant, context, and jobs paths. The footer directs the backend to begin with context/README.md when useful, protect SOUL.md and evals unless asked, write requested jobs directly under jobs/, and validate them before reporting success. Push does not write the footer to the repository or inject all context files into each prompt. The selected backend and its configuration decide what to inspect.

Sessions, databases, audit logs, delivery state, locks, config secrets, and other runtime state stay outside the Git-versioned assistant repository. Project-scoped skills and their helper scripts may live in the assistant repository. The backend still owns skill discovery and execution, global skills, permissions, MCP, and authentication.

Concurrency

One worker task exists per conversation thread. Messages in the same thread run in order. Different threads can run in parallel.

/stop cancels the active backend subprocess for one conversation without discarding messages already queued behind it. A closed worker queue is replaced and the message that discovered it is retried once.

This prevents two messages in the same conversation from racing against the same backend session.

Security Posture

An allowed inbound message can cause an agent to run tools. The sender filter is the trust boundary. iMessage uses imessage.self_handles and imessage.allow_from; Telegram uses stable numeric telegram.allow_user_ids and telegram.allow_chat_ids; Slack uses stable slack.allow_user_ids member IDs and verifies the authenticated workspace.

Push preserves sandbox, approval, permission-mode, and tool-list settings for chats. Codex and Claude jobs bypass interactive permissions so unattended work can complete. Every job must use a work directory that does not overlap Push-owned state or configuration.

Roadmap

The following items are possible future work, not shipped behavior or release commitments:

  1. More agent adapters.
  2. More channels.
  3. Memory write-back with audit and review.
  4. Per-task backend routing.

Avoid adding a gateway plugin system until there is a specific capability that cannot live in the selected backend.