Skip to content

Push Architecture

Push is one local Rust process. It polls one or more configured iMessage and Telegram 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
Agent runtime        = reasoning, tools, 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. Polling Only

Push polls channel state and shells out to local agent commands. It opens no server port and accepts no inbound network connection. Telegram uses outbound HTTPS long polling.

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]
    db -->|poll| push
    tg -->|long poll| 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/] --> 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
    db -->|reply| user

Message Lifecycle

sequenceDiagram
    participant U as User
    participant DB as chat.db
    participant P as Poller
    participant G as Gateway
    participant W as Worker
    participant A as Agent backend
    participant S as Sender

    U->>DB: send message
    P->>DB: read rows newer than last_row_id
    DB-->>P: messages
    P->>G: Message values
    G->>G: filter sender and reply marker
    G->>G: 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->>W: persist generated outbound message
        W->>S: send reply
        W->>G: ack completed row
    end
    S->>DB: osascript send
    DB->>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
  },
  "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.

With advanced channels = ["imessage", "telegram"] 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. 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 three 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-written job proposals live separately under origin-specific inboxes in drafts_dir. Push creates the exact inbox and includes its path in the route's instructions. The selected agent's configuration decides whether it and the assistant root are writable. Push's approval flow governs drafts it installs; it does not prevent a write-enabled agent from changing installed jobs directly. Push snapshots and validates a changed regular file, stores its exact bytes, hash, proposer, and bound approval question in SQLite, then sends the full proposal to the originating channel. Reconciliation also runs after backend failure or timeout and before replaying a persisted outbound reply, so a crash cannot orphan an unrecorded revision. Approval rechecks the path, symlink status, revision, job schema and protected work directory. A valid stored revision is staged inside the derived assistant jobs/ directory and installed with an atomic no-clobber link. Rejection and revision mismatch never activate the draft, and consumed or duplicate answers cannot repeat installation.

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 installed jobs, and use the job draft approval flow. 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. Instructions include the absolute context/ path; Push does not add it as a writable root.

Sessions, databases, drafts, audit logs, delivery state, locks, config secrets, and other runtime state stay outside the Git-versioned assistant repository. The backend still owns tools, skills, MCP, authentication, and execution.

Concurrency

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

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.

Push does not override sandbox, approval, permission-mode, or tool-list settings. Chats and jobs use the selected agent's own configuration and must use work directories that do not overlap Push-owned state or configuration.

Extension Points

The next extension points should be added in this order:

  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.