A coding agent is a loop around a list of messages. This post follows one prompt through the Rust code of Codex CLI, the foundation Elpis is forked from, and marks the exact places where Elpis hooks in.
Code is quoted verbatim from commit e2efa7a5. Paths are relative to codex-rs/. Nothing here was run for this post; it is read from the source.
How to tell Elpis code from Codex code. All of codex-rs arrived as one import of openai/codex at revision 2e1607ee (ELPISUPSTREAM.md), committed as f37fc774. A file that exists at that commit is Codex's; one that does not was written by Elpis:
1. Two queues, and typing while it works
The UI never calls the agent. It pushes a Submission { id, op } into a bounded channel (capacity 512) and reads Event { id, msg } from an unbounded one (both types are in protocol/src/protocol.rs; the channels are made in Session::spawn). One background task, submissionloop, routes each Op. Interrupts and approvals are handled inline, which is how an approval can reach a turn that is already running.
Typing while the agent works does not start a second turn. userinputorturninner first tries steerinput, and only spawns a new RegularTask if that fails with NoActiveTurn:
Your message is seen at the next sampling step; tool calls already in flight finish first.
2. What the model actually receives
Nothing is stored server-side. The request sets store to true only for Azure in core/src/client.rs:
The whole history, including encrypted reasoning items, is re-sent on every request of the loop.
That is only affordable because of the prompt cache, and it explains a design choice: context is append-only. On the first turn (and right after compaction) Codex builds the full context, meaning developer messages for permissions, collaboration mode and skills, plus one contextual user message holding instructions and environment. On later turns it appends only diffs (recordcontextupdatesandsetreferencecontextitem in core/src/session/mod.rs). Each fragment is wrapped in text markers (# AGENTS.md instructions ... ) so the code can find and replace it later (context-fragments/src/fragment.rs).
Before every request the history is normalized: a tool call with no output gets an "aborted" output, and an output whose call is gone is dropped:
The API rejects unpaired calls, and an interrupt can leave one behind.
3. The loop
The stream is parsed into ResponseEvents. Text deltas go straight to the UI, but an item only enters history when it is done (handleoutputitemdone in core/src/streameventsutils.rs).
If the item is a tool call, the call is recorded at once and its execution is pushed onto a FuturesOrdered, so tools start running while the model is still streaming. An RwLock decides concurrency: tools that support parallelism take a read lock, the rest take the write lock and run alone:
After Completed, the outputs are drained and recorded in call order, which keeps the history deterministic (draininflight in core/src/session/turn.rs).
The turn continues while needsfollowup holds: there were tool calls, or the server said endturn: false, or you steered mid-turn. Otherwise Stop hooks run, and can block the stop by injecting a continuation prompt. There is no planner and no state machine underneath. "Agentic" is this inner loop.
4. Running a command
Routing. A FunctionCall becomes JSON arguments; a CustomToolCall (applypatch uses this) becomes free text. An unknown tool name is returned to the model as an error, not a crash. Hooks wrap every tool, built-in or MCP, at one choke point (buildtoolcall in core/src/tools/router.rs, dispatchanywithterminaloutcome in registry.rs).
Policy. Rules are Starlark prefixrule(pattern=[...], decision=allow prompt forbidden) files, loaded per config layer (loadexecpolicy in core/src/execpolicy.rs). A bash -lc script is split into its commands, each is matched, and the strictest decision wins, because the decisions are ordered Allow < Prompt < Forbidden:
The derived Ord follows declaration order, so frommatches in execpolicy/src/policy.rs just takes the .max(). Commands that match no rule fall to heuristics: a known-safe list, a dangerous-command check, and the approval policy.
One choke point. Shell, unified exec and applypatch all go through ToolOrchestrator::run: approval first, then a sandbox, then a possible retry (core/src/tools/orchestrator.rs). Approval is decided by a hook, then by the Guardian (an automated reviewer agent) if configured, then by you (core/src/tools/approvals.rs).
The sandbox, on Linux. The command is prefixed with codex-linux-sandbox. It builds a bubblewrap jail (--new-session --die-with-parent --unshare-user --unshare-pid, --unshare-net when the network is off, the root mounted read-only, writable roots bound back in), then re-execs inside it, sets PRSETNONEWPRIVS, and installs a seccomp filter that blocks ptrace, processvm and iouring, plus network syscalls in restricted mode (linux-sandbox/src/bwrap.rs, linuxrunmain.rs). The blocked syscalls are listed plainly:
Landlock is only a legacy fallback behind features.uselegacylandlock.
Limits. A command times out after 10 seconds by default and its whole process group is killed (DEFAULTEXECCOMMANDTIMEOUTMS in core/src/exec.rs). At most 1 MiB of output is kept in memory, and the model sees it trimmed again by the model's truncation policy.
applypatch is a custom diff format with no line numbers. Hunks are found by matching context in seeksequence, which tries an exact match, then ignoring trailing whitespace, then ignoring all surrounding whitespace, and last of all treating typographic dashes and quotes as their ASCII twins, so small drift in model output still applies. The first three comparisons, one per pass:
The patch is checked against the real filesystem before approval is decided, and it is applied through a sandboxed filesystem helper, not a shell (handlecall in core/src/tools/handlers/applypatch.rs). Models often run applypatch through the shell anyway, so runexeclike detects that and reroutes it (core/src/tools/handlers/shell.rs).
MCP tools never touch the OS sandbox: the server process is launched with sandbox: None, and approval comes from the server's own destructive/read-only annotations:
Skills are not tools at all; a mentioned skill's SKILL.md is injected as context.
5. When the window fills
The context size Codex acts on is part measurement and part guess: the server's total from the last request, plus a 4-bytes-per-token estimate for everything added since (gettotaltokenusage in core/src/contextmanager/history.rs).
Auto-compaction triggers at 90% of the model's window:
It is checked before a turn and, mid-turn, when a follow-up is needed. The local backend sends the history plus a "context checkpoint" prompt as an ordinary request. The replacement history is the most recent user messages that fit in 20k tokens plus one user-role message holding the summary:
Your messages are walked newest first, and the summary goes in with role: "user". Every tool call and every assistant message is gone. The model does not remember its own earlier outputs; it reads a summary of them. For OpenAI and Azure the default backend is instead a server-side compaction that returns an opaque encrypted item.
Each thread appends to sessions/YYYY/MM/DD/rollout- - .jsonl under CODEXHOME, which Elpis defaults to /.elpis. Resume reads that file backwards until it reaches the last compaction checkpoint, which carries the full replacement history, and replays only the tail (reconstructhistoryfromrollout in core/src/session/rolloutreconstruction.rs).
6. Where Elpis hooks in
Smart Prune is one call inserted into the loop. Codex collects finished tool outputs and records them into history. Elpis added optimizependingoutputs between those two steps:
Because it acts before a result is first admitted, the existing prefix is never rewritten and the prompt cache stays valid; the older force-prune rewrites history and discards it.
Eligibility. A result needs at least 1,024 estimated tokens, a batch stays under
24,000, and a result is only replaced if that saves at least 256 tokens and at least 20%. Runtime failures and hook or policy feedback are never touched, so control text stays exact. The batch cap is MAXPRUNEBATCHTOKENS in contextpruner.rs; the other three floors are here:
The optimizer call. One extra request with its own system prompt, Low reasoning
effort, a 180-second timeout and a strict JSON schema. On the OpenAI provider the default optimizer is gpt-5.6-luna (runmodeladmission and selectedmodelslug in core/src/session/smartprune.rs).
The contract. Every call ID must appear exactly once, as compact with content or
unchanged with none. An unknown, duplicate or missing ID rejects the entire batch. A kept result ends with a pointer, exactsource=smart-prune:// / sourcesha256=…, back to the original. In parsedecisionmanifest, every return None throws the whole batch away:
Audit first. The record is written to a .pending- directory, fsynced, and
renamed into place. Only then is the shortened result recorded (writeadmission in core/src/session/smartpruneaudit.rs).
Fail open. Any failure keeps the original. A failed batch also switches Smart
Prune off for the rest of that turn (recordbatchfailure in core/src/session/smartprune.rs), and a toggle takes effect on the next turn, because the flag is copied into the turn once at its start.
The Context Ledger is a TOML file. admission.toml lives under /context/workspaces/ / and records which sources are admitted: AGENTS.md (global and project), GOAL.md, ES.md, MEMORY.md and the dev rule files (core/src/elpiscontext.rs). Excluding one changes the request through two gates. Continuity files go through ElpisContinuityExtension, which emits a single developer fragment headed ## Elpis Admitted Context and only re-emits it when the text changes. AGENTS.md is already sent by Codex, so Elpis patched agentsmdmanager to filter it, and the cache key includes an admission fingerprint so a toggle applies on the very next request (contributeworldstate in app-server/src/extensions.rs, refresh in core/src/agentsmdmanager.rs). There is no protocol call for this: the TUI writes admission.toml itself through core (setcontextsourceadmitted in tui/src/chatwidget/contextledger.rs) and the app-server re-reads it each turn.
Memory is one more tool. savememory is registered only when the turn began with a baseline snapshot of MEMORY.md and ES.md, and only for the root agent. It applies exact append, replace or remove edits, refuses to write if either file changed since the turn began, and writes a receipt marked prepared, then MEMORY.md, then ES.md (rolled back if that fails), then marks the receipt committed (commitupdate in core/src/memorysave.rs).
Providers are an adapter. At import, WireApi had one variant, Responses. Elpis added Anthropic Messages, Gemini and Chat Completions (the WireApi enum in model-provider-info/src/lib.rs). Every non-Responses provider goes through one function that translates the Responses-shaped request into the provider's format and translates the stream back into ResponseEvents (streamnativeapi in core/src/client.rs, and core/src/chatcompletions.rs). The turn loop, tools, history and pruning still only see Responses items, which is why swapping providers touches so little.
Work graphs. The enablefanout flag is not an Elpis flag: it is Codex's own SpawnCsv feature, under development and off by default (features/src/lib.rs). Elpis's runagentworkgraph is registered beside the CSV fan-out tool. A graph is validated before anything is stored, and a topological sort rejects cycles. A task is ready when every dependency has succeeded, and a task only counts as done if its report passes an evidence gate: files outside its scope, a changedfiles list that disagrees with the engine's own before/after snapshot, or empty evidence are all rejected (reportworktask in core/src/tools/handlers/workgraphs.rs). Only one writable task runs per environment at a time, whatever their scopes (taskshavewriteconflict in the same file).
7. Things you would not guess
Under the OnRequest approval policy, a command that fails inside the sandbox is
not retried outside it. The model has to ask for escalation up front:
An Allow rule removes the sandbox entirely when every segment of a script matches an
explicit Allow. It is a trust grant, not just "skip the prompt":
The "Linux seccomp" sandbox type is bubblewrap plus a seccomp filter, and the file
named landlock.rs holds the seccomp code (SandboxType::LinuxSeccomp in sandboxing/src/manager.rs, and the header comment of linux-sandbox/src/landlock.rs).
The context-size number is partly an estimate, so the compaction trigger fires on a
guess as well as a measurement.
Compaction keeps your messages and drops the agent's own: tool calls and replies are
replaced by a summary written as a user message.
Toggling Smart Prune or a Ledger row applies at the next turn or request, not
mid-flight.
Inferred, not tested: since the Ledger writes a file rather than calling the server, a
remote app-server would see a different file from the one your TUI edits.
Status
Smart Prune is experimental and off by default. In a frozen synthetic study its cost depended on the horizon: short sessions cost more, and sessions of about 35 requests cost less. Whether it changes task success remains unproven.