Map: exarch
exarch is a small LLM coding agent — a separate workspace binary that
embeds ral-core (ral-core = { path = "../core" }). A model
(Anthropic / OpenAI / Gemini / xAI / Qwen / OpenRouter / DeepSeek / OpenCode, a
custom provider, or a signed-in ChatGPT account) is
given one ral tool, and each call is evaluated as a ral top-level run
against a persistent in-process Shell, under capabilities the user chose. It
ships as one executable (single-binary); the same
binary re-execs itself as the OS sandbox child.
The agent is a provider loop over one tool, each run grant-framed — the design argument lives in exarch-architecture. This page maps the binary’s front door: what runs at startup, and how the system prompt is assembled.
Entry and dispatch
run (in lib.rs, lifted out of main so integration tests link the whole
crate) is the session path; main.rs is the thin shell over it. On the way out,
main runs ral_core::sandbox::teardown_session — deleting the session’s
AppContainer profiles on Windows, a no-op elsewhere — before surrendering the
exit code.
- Pre-
maintrampoline. Before any setup,exit_if_re_exec_childserves and exits a re-exec child: it hands the roleral_core::classify(argv)names to core’s one pre-maindispatch,ral_core::invocation::serve_process(startup), over exarch’sINSTALLERS(whosenarrowispolicy::base_layer, since core carries no base-tag lexicon of its own and a wire-seeded child resolves its own layer while booting). A confined child is served before any pin, the anchor unpinned; every other role — the--engineseat child included — is pinned by `sandbox::boot` before it runs.mainand every test#[ctor](pre_main_ctor!) share this dispatch and its exit — they differ only in where argv comes from: a constructor callsserve_process_before_main, because on musl std has no argv beforemainand only/proc/self/cmdlinedoes. That names the role but not the process it runs in: std’s argv stays empty, and uucore reads it, so no constructor on musl serves a bundled tool. Tests that need one are ignored undercfg(ctor_without_argv), which core’s and exarch’s build scripts set on Linux without glibc; a unix-only test that needs only some process names a host program by path (/bin/sleep). A test binary reachesmainonly through libtest yet is the same multicall executable a child re-execs; skip the dispatch and the confined transport stays unpinned, so that binary’s confined-path tests cannot run. - Subcommands (
cli.rs) run an out-of-band action and exit before any session setup:login/logout/accountsmanage signed-in ChatGPT accounts (see below),--modeland the session flags are ignored on this path.export <session> [--out FILE]renders a session’srecord.jsonl(given as its directory or the file) as the plain-text transcript the scrollback showed, to standard output or a new file, through the sametui::export_tothat/exportuses (session-record). - Session. Absent a subcommand,
runsettles what to open on (--providerpins the identity;--modelnames a model its account must list;--vianames the upstreams that serve it on a routing service;--effortnames a rung of the/modelpicker’s ownEFFORT_LADDER;--chatdrops the system prompt and all tools). The saved project selection is the ground each of those is laid over. A choice the flags name must build or the launch fails; a saved choice that no longer builds, or no choice at all, opens the picker alone on the screen before any session exists (tui::choose) — or, headless, fails asking for--model. A session is therefore born with a listed model and never holds none, and the choice it opens on is saved back, so a flag and the picker leave the same trace.--role NAME=MODEL(with--role-provider NAME=ACCOUNTto break a tie, and--role-via NAME=TAGSto choose its upstreams) binds the models a spawned agent may run on, minted at launch with this run’s--effortand--max-tokensand never saved; without roles every child inherits (provider). Thenruncomposes the capability lattice (policy::for_invocation, → policy), assembles the system prompt (prompt::assemble), builds the first root `Avatar` viaAvatar::root(RootConfig, RootSeat, provider)— or, for the TUI under--resume, every live root of the run viaAvatar::resume— the ral binary seats it onRootSeat::Identity; a wire seat drives a remote engine instead, and synod reuses the same construction — and hands off to one frontend — the inline TUI or the pipe-friendly headless runner, selected explicitly by--headlessor implicitly by--prompt(which refuses to resume a run with more than one open session) — which shares the roots’ one `Fleet`.
Accounts
One service may own many accounts. chatgpt is a single service, and a
login email can carry several accounts under it — a personal one and one per
workspace, each with its own issued id. Each account is separately selectable
in the /model picker, exactly like any API-keyed one, so there is no second
account dimension; a key-bearing service is simply the case where the service
owns exactly one account and lends it its name. The shape is
`Service` × `Account`.
loginsigns in through OpenAI’s “Sign in with ChatGPT” for open-source apps (a browser redirect to a loopback listener): bare, it registers a new account;login <account>renews an existing one under its own issued client. There is no device-code flow./loginperforms the same sign-in inside a running TUI session, then admits the returned shared OAuth credential to both the live store and model catalog; a refresh of the selected account is visible to its next request.logoutremoves one account by email or id, or--all. An email two accounts answer to names neither, so it is refused with both account ids rather than taking whichever came first.accountslists the signed-in set, each named byidentity::labelagainst the others present.
The token store is an object keyed by the rendering of an AccountId, one
entry per account, and so is every map above it. run resolves each login into
its own account, ordered after the key-bearing ones. Because a key-bearing
account’s id is its service name, an existing state.json, model cache
entry, or --provider deepseek keeps working untouched; only a ChatGPT
selection changes key.
Credentials and the env scrub
provider/credential.rs resolves every provider’s secret once at startup and scrubs
the key variables from the process environment, so no child a tool call spawns
can inherit a live key.
CredentialStore::resolve_and_scrubsweeps every known account — the built-in table and the endpoints declared inconfig.ral— reading eachAuth::Keyservice’s key variable into the store’s environment layer, then removing from the environment every key variable that was present, whether or not it yielded a usable key. A malformed value (a pasted newline) is still a live secret, so it is swept too.- Removal is blank, then unset:
unsetenvdrops only libc’s pointer, while/proc/<pid>/environreads the bytes the kernel laid down atexecve, so each value is overwritten in place first, and the process is made non-dumpable before any of it (a-grant-never-reaches-its-own-key). - The scrub makes resolution eager: once a variable is gone it cannot be re-read, so a key absent at startup stays absent for the run. Read and removal happen while the process is still single-threaded — before any session worker — so the env mutation cannot race.
- A signed-in ChatGPT account never touches the environment: its login is
loaded from the OAuth token store into its own
OAuthcredential cell ().chatgptis a different service fromopenai, so a login and anOPENAI_API_KEYcoexist, and two logins never share a cell. - This is the subtractive half of exarch’s env shaping; the additive half —
NO_COLOR,$EXARCH_SCRATCH, and the redirected tool homes — is seeded onto the session shell (). Per-spawn loader-variable hardening (strippingLD_PRELOAD/LD_AUDIT/LD_LIBRARY_PATHunder an active grant) is ral-core’s, not exarch’s. The why is provider-config-ral-script; the boundary a child actually inherits is ral’s grant.
Bootstrap
Two modules hold the once-per-process pieces; nothing here is per-run.
app.rs is the apps and their directories (App, EXARCH, SYNOD, the XDG
and project dirs, Scratch, RunLock, the time and slug helpers); boot.rs is
the shell every seat starts from.
engine_runtimeandengine_root(boot.rs) — the installer’s two halves for both seats, run engine-side at Attach:engine_runtimeis core’s `ral_core::boot::boot_runtime` with exarch’s host surface (builtins::host_surface()) so the host builtins ride construction, and a child engine runs it too;engine_rootis the root-only recipe over a root session:library::install_agent_libraryfor theagent.ralhelpers, ANSI colour suppressed at the source (seed_no_color), the scratch theAttachnames (it refuses one that names none: the host owns it), andshell_eval::arm_session_ledgers— the one policy site arming the binding lease and settled-worker retention. It has no signal ceremony and no terminal probe (a cancel arrives as aControlframe).face_process_signals(boot.rs) — the signal ceremony, once at each entry point that hosts an identity engine: clear stale ral interrupts, install ral’s handlers, chain exarch’s cancel over them (signals::install), seat the terminal.- Machine probing —
prompt::host::snapshotformats the live machine into the prompt’sHostsection:now(viajiff) andgit(viagit(1)) are probed there,cwd,userandhomecome fromral_core::host; best-effort, a missing value drops its line. Scratch— the disposable per-session directory, exposed under itsApp’s own name ($EXARCH_SCRATCH; synod’s is$SYNOD_SCRATCH), with every cache a session writes redirected into it:TOOL_HOMES, one table of variables, each replacing the inherited value with a subdirectory of the scratch (XDG_CACHE_HOME,CARGO_HOME,GOPATH,GOCACHE, the pnpm, Bun and Yarn stores, …) or, for Maven, which has no such variable,MAVEN_ARGSset to-Dmaven.repo.local=<scratch>/maven. The table is spelled in two namespaces by oneenv_under: a hostScratchjoins its homes natively (join_slash), andguest_scratch_env, a function of the app and aGuestPathalone, which synod’s guest needs, joins with/on every host (GuestPath::join), since a guest path lifted into a hostPathBufwould join/tmp\pipon Windows.Seat::rootattaches its identity engine atEnginePath::Hostpaths, the launching user’s cwd andHOME. No profile writes the user’s caches, so a write lands in the scratch rather than in a denied real one.RUSTUP_HOMEis not in the table: it holds what runs and stays the host’s. AHold::Session’s drop deletes the directory, then closes and removes its lock;reap_unheldremains for a session killed before its drop runs. Beside the table is the one setting the confinement forces without relocating anything (CARGO_NET_GIT_FETCH_WITH_CLI, because cargo’s bundled TLS reaches the keychain door the profile withholds — see capabilities).App— the product identity that owns the directory conventions as methods, and the engine names every product itself (EXARCH,SYNOD, overAPPS) rather than each naming its own: composing a grant means carving out every product’s credential directories, so one place has to know which apps there are.App::xdg_diris the one spelling of$XDG_<kind>_HOME/<app>/(over core’spath::app_dir_or_temp, which ral’s own directories share) that the project state, model cache, and trusted config home all build on (exarch-config-dir);App::project_dirkeys per-project state by a slug of the launch cwd (ral_core::path::project_slug), holding the persisted model selection (state.json); andApp::log_run_diris the durable per-run session-log directory$XDG_STATE_HOME/<app>/<project>/<run>/, so logs survive an abnormal exit; each session’ssessions/<id>/holdsrecord.jsonlalone.
System prompt
prompt::assemble builds an agent-invariant base from (heading, body) sections
walked by one uniform renderer, in order persona, Ral, Editing, Builtins,
Tasks, Context management, Host, [Workspace], [Skills],
[Surfacing]. The builtin placeholder and the late sections are resolved
once per constructed
agent, so a root, identity fork, and wire child each receive their own surface.
- Persona (
data/system.md, unheaded — it sets the tone, not a topic) frames the session as one continuing shell script: definitions, working directory, and worker threads persist across turns. It teaches selective binding, bounded inspection, batching known steps, and spawning independent long-running work under a title, thenawaiting its handle — orawait !{worker <id>}from the completion notice — when the result is needed. Ral(data/ral.md) is the language and tool reference. The CBPV introduction leads into values, computations, capture, and forcing, followed by help and exploration. String arguments remain intact; list spreading and glob expansion are explicit. Blocks retain their lexical bindings when a helper is shadowed. Examples collect multiple reads into one returned value and force computations inside homogeneous lists and heterogeneous records; string guidance distinguishes variable delimiters from command capture and teaches hash-delimited raw strings as the spelling for literal text. Specialist details live inexplain redirects,explain within,explain return,explain grant,explain try,explain await,explain poll, andexplain detach; the prompt keeps common forms and examples. Failure guidance separates recovery, inspection, and deliberate suppression. The concurrency section teaches one pattern:spawn "title" { … }, other work or the end of the turn, a host notice when the worker ends, thenawait, which behaves as the block run on that line — its output written there, its value returned, its failure raised, sotryrecovers it — and claims the result once, so a secondawaitfails.worker <id>recovers a lost handle,workerslists every worker,pollsamples a long-running server without waiting or claiming, a block’s own spawns finish before it does, and fan-out that must leave nothing behind is agather { … }, withpar,both,first-ofover it andracefor handles already held; agatheris synchronous, so work that should outlive the turn is a loosespawnoutside it. A loose worker lives as long as the session;detachis for a process that must outlive it. The audit example distinguishes an expected nonzero exit from an error; independent calls use individualattempts so one failure does not skip the rest, then inspect each command’s status rather than the enclosing audit’s success. Exarch includes this reference directly; synod appends its own office examples (synod).Editingdocuments the file-editing scheme the--editflag selects: line-hash overview-hash(data/edit-hash.md,--edit hash) or string-replace overview-text(data/edit-replace.md,--edit replace); only the prompt text switches, every reader and editor stays registered (hash-addressed-editing).Builtins(builtin_index) lists every builtin and prelude function by name only — a progressive-disclosure index the agent expands at runtime withhelp/explain, so the prompt cannot drift.assemblebakes a placeholder here: the real per-agent list — filtered to the harness verbs that agent holds — is resolved byBuiltinIndex::apply(agent/index.rs) once the agent’s own grants — itsRole,allow_schedule,spawns(fuel > 0) — are in reach, without a liveShell; zero-fuel agents omitagentsalone.Tasks(data/tasks.md) is the task-management kit API.Context management(data/context.md) teaches the coding workflow: evict irrelevant retrievals and failed attempts once retried, each as one run[from, to]under a required note that leaves one residue; cut at the bottom or in batch, covering a residue whole or not at all; expect the a forced eviction to take the current prompt too, so evict first under one’s own note, saying what was asked; and find residues again withexarch-transcript. Each instruction is paired with an example call; the operation-by-operationexplainguides inshell_eval/builtins/harness.rsown returned shapes, limits, refusals, and recovery details. Eviction changes visibility, never filesystem or shell state.Hostopens by naming the reader — the one host fact that is not the host’s, since a name is what every other agent addresses it by and the only way it picks its own row out of a roster — then gives the environment snapshot (host::snapshot, ) with the live grant under it: a static legend (data/grant-legend.md) over the capability bullets, or one ambient-authority line when nothing is attenuated. The name is a second placeholder beside the builtin index (NAME_PLACEHOLDER), filled per agent byBuiltinIndex::apply, because the section is otherwise baked once at boot. The grant is a third (GRANT_PLACEHOLDER), filled per agent byprompt::with_grantfrom its own engine’sProbe::Grantsfollowed by the ceiling its host pushes per run: a child’s layer is resolved in its own engine (Shell::child_of), so only that engine can name it. A child’s session already holds its parent’s ceiling, which the host pushes again;grant_summarytells a repeated layer once, the meet being idempotent.Workspace(discover_agents) collects theAGENTS.mdinstruction files, outermost first so the deepest file’s recency wins: the operator’s<config>/AGENTS.md, then every repoAGENTS.mdfrom the git root down to cwd (the walk stops at the first.gitentry; outside a repo, onlycwd/AGENTS.md). Present whenever any is found; project guidance that cannot widen the grant (agents-md-injection).Skillslists each discovered readable skill as onename: descriptionline, loaded on demand with theskillbuiltin — progressive disclosure again.Surfacing(data/surface.md) belongs to the interactive base and carries the five Bertin marks and role set (surface-carries-documents, → cards). Per-agent resolution then appendsAgents(data/agents.md) ifffuel > 0, thenSpawned(data/spawned.md) iff the agent is a child, thenReply(data/reply.md) iff it returns at all.Replysays only what is true of any receiver;Spawnedcarries the spawner’s snapshot, its binding the reply, and the idle wait for follow-ups. A headless root therefore getsReplybut neverSpawned.Agentsuses point/call examples: pass bindings, request a reply shape, assign disjoint write grants, wait without polling, read, follow up, and cancel.Roles(data/roles.mdplus onename: modelline per--role) followsAgents, iff the agent spawns and the human bound any. The detailed permissions, schemas, and fan-out examples live inexplain exarch-agents.
The assembled prompt is a fact of the session: every opening of a log (a root,
a fork, a /new or /branch root) records it as Protocol::System, and a
resume records the freshly built prompt only if it differs from the one in
force. A round-trip reads it off the model fold, so Agent and Birth hold no
copy.
--system FILE... replaces only the persona slot with the user-supplied files;
the per-agent index and optional sections still resolve from the stored base.
Subsystems
- agent — the uniform node and the thin
Fleet: the attend loop (provider round-trips, tool-call batches with prompt-queue steering, context eviction, the nudge-retry policy, sub-agent fork), theRoleeach node is born with, the owned hot-swappable provider, dynamic focus, and the subtree cancel cascade. - provider — LLM transport over genai: streaming, the retry driver, prompt caching, usage and dollar accounting.
- shell-eval — one tool call as a ral top-level run under a pushed capabilities frame; the streaming digest and the surface host sink.
- policy — capability composition (base ∨ extend ⊓ restrict) and the bake-in profiles; the boundary is ral’s grant.
- tools —
ralis the tool, andthinkinga hidden-flag relay beside it;provider/tools.rsholds the wire half (a staticToolSpecand aCopyToolsetthe agent carries, with no registry) andagent/tools.rsthe dispatch. Every other harness verb — theexarch-agents `startspawn (one record-spec tag,`amnemon/`mnemonby field, fuel-gated,`reply/`readgated on a returningRole), the schedule family — is a builtin reached through it, answered by the desk. The sub-agent model is agents. - builtins — the resident host atoms and the harness
verbs: the hash-addressed edit primitives, the spawn/schedule/reply
family, and the
agent.ralhelpers (why). - frontend — the agent/UI boundary (event bus, session log) and the two frontends, the inline TUI and headless.
- cards — the render document
surfacecarries: acardof closed Bertin marks decoded once and drawn by one generic interpreter; open card set, closed mark set. - io-surface — every redirect read/write and exec image as one card: core emits an I/O event at the runtime doors, exarch binds it to a mark; the closed door set is clippy- and meta-test-enforced.
Sandbox
exarch does not invent its own sandbox. Each tool call is one transport Run
carrying the profile’s capabilities in Run.caps, dispatched across the
engine protocol (shell_eval::run_shell, → shell-eval) and
pushed onto ral’s capability stack by core’s run door — so the safety
boundary is ral’s grant mechanism — authority attenuated by
intersection. There is no source-level grant { … } the model could escape;
the frame is installed by the host. Profiles ship as .exarch.ral files in
exarch/data/ (dangerous, reasonable, edit-only, read-only, minimal,
confined); see exarch/PROFILES.md and policy.
Scheduled wakeups
With --allow-schedule, the agent may schedule its own wakeups (schedules)
— a cron expression or a one-shot after <dur>. A wakeup schedules the
agent, not a worker: at its time a synthetic user item is posted to the
agent’s own inbox and delivered at the exchange boundary, re-engaging the
loop with no human present. It is off by default — waking yourself
indefinitely is real authority. The inbox/reaper mechanics live on the
frontend and agent pages; see
scheduled-wakeups.
Where to look
exarch/data/{system.md, ral.md, edit-hash.md, edit-replace.md, tasks.md, context.md, grant-legend.md, surface.md, agents.md, reply.md, agent.ral}— the persona rules, ral reference, editing schemes, task kit, context discipline, grant legend, surfacing guidance, returning-agent and spawn contracts, and the embedded agent helper library.- Provider configuration — a famous provider auto-populates from its env key, an
unusual one from a hand-written XDG
config.ral(provider-config-ral-script). exarch/README.md,exarch/PROFILES.md— human docs.
repl is the sibling ral binary over the same engine.