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:
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:
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:
- More agent adapters.
- More channels.
- Memory write-back with audit and review.
- Per-task backend routing.
Avoid adding a gateway plugin system until there is a specific capability that cannot live in the selected backend.