Skip to main content

8. Design decisions in force

This page states the structural decisions that hold today, and why. It is written in the present tense: it describes the system as it is built, not the route that led there. Where a decision has a cost, the cost is named.

Stack and runtime​

The runtime is Rust on Tokio. The code a package brings is Python, loaded in-process through a PyO3 bridge rather than spawned as subprocesses: the bridge translates Rust futures to Python coroutines directly, so a call into that code costs no process boundary and no serialization round-trip.

Every subsystem that owns mutable state is a Tokio actor with a bounded channel and a clonable handle. No state is shared between actors behind a lock. The cost is indirection, and it buys the property that matters at this size: a deadlock cannot form between two subsystems that only exchange messages.

Persistence is SQLite in WAL mode, vendored into the binary. Nothing on the default path requires a server, a daemon, or a network.

The agent contract​

An agent is a record, not a program: a mission, a model, the tools it is granted, the sources it may read, and its rights. A package declares it as an [[agents]] entry of its apollia.toml manifest, and no Python object stands for it. What the package declares is a set of defaults, never a constraint: the user changes a tool, a source, the model or the autonomy tier from the interface, and the local override wins without a new version of the package.

Code is optional. The Python of a package brings tools and workflows, async functions under @tool and @workflow, and the SDK publishes no other decorator. python -m apollia describe writes their descriptors into the manifest, so reading a manifest never runs the code it describes.

The runtime reads the manifest through one parser, under a strict schema. A key the schema does not know is refused with its full path, and so is what the schema cannot express: an identifier declared twice, an access that names a package tool the package does not deliver, a package that declares nothing. A package whose manifest does not validate is neither built nor installed.

The runtime hands each tool and each workflow a ctx object exposing the services it may use. What it carries depends on the caller. A package tool gets a logger and the identity of the agent that called it, with no model, no tool proxy and no memory: the engine of the calling agent runs those, not the tool. A package workflow adds the models (ctx.llm), the agents and workflows it may run (ctx.agents, ctx.workflows) and a question to the user (ctx.user). ctx is the whole runtime surface. Code reaching around it is using something the runtime does not guarantee.

Payload schemas are TypedDict, read when a function is decorated to build the schema of its inputs. This is why deferred annotation evaluation is refused in those modules: it turns annotations into strings, and the schema comes out empty with no error.

The Python SDK has no third-party dependencies. Every one would become a dependency of every package, on every machine, forever.

This contract replaced another one on 2026-09-30, when principle 3 was rewritten. Until then an agent was a Python class under @agent with @skill or @on_message methods, and the bridge refused any object without the dispatch entry point those decorators installed. Two observations led to the change. An agent that fixes in code the tools it requires is a workflow written in code: the person who uses it cannot add a tool or change a right without a new version. And one name covered objects whose control was not held by the same party, the model in one case and the code of the author in another, so nobody could tell which one they had in front of them. The cost is a break: an agent written as a class no longer loads, and is rewritten as a manifest entry with its Python moved into tools or into a workflow.

Execution model​

Two things run, and one question tells them apart: who decides the next step. For an agent, the model does. For a workflow, the author does.

An agent runs on the autonomous engine, which observes, reasons, and acts in a loop. Its mission is the system prompt, the accesses it is granted are the tools the model is offered, and its model and its autonomy tier are resolved when each message is sent, so a change on its card applies to a conversation already open. The engine runs in two modes: direct, where the model answers and calls tools as it goes, and orchestrated, where a plan is built and its steps executed. The planning setting of the agent chooses: never, always, or auto, which plans when the task reads as a multi-step one.

A workflow is Python whose steps are declared ahead. Its body opens each step by name, and a name the declaration does not hold is refused. A step may run an agent or another workflow: the nested run stops with the run that made it, spends from the same budget, and nesting is bounded at three levels, the first run counting as one. The cost of the split is that a fixed sequence can no longer pass for an agent. It is written as a workflow, its structure is visible, and its user changes its inputs, not its steps.

On the orchestrated path, step arguments are filled at plan time under a grammar, with just-in-time extraction as a fallback at execution. Filling them at plan time is what lets a plan drive real tools with structured arguments instead of re-parsing prose at each step.

A completed orchestrated run is verified by a critic from the supervised tier upward; the assisted tier runs no verification pass at all. The verdict is recorded as a runtime event, and a failing verdict triggers bounded re-planning under the same budget that bounded the original run.

Budget and safeguards​

Every run carries a step budget with three dimensions: a maximum number of steps, a maximum number of tool calls, and a wall-clock ceiling. The runtime enforces it. Agent code cannot raise it, extend it, or opt out of it, because the counter lives on the runtime side of the bridge and the agent never holds a reference to it.

This is the safeguard the design rests on: an autonomous loop that can be stopped is a product, and one that cannot is a liability.

Supervision​

The supervisor is fail-fast then degrade. An actor that dies is not restarted. The runtime reports the loss, degrades the capability that actor served, and keeps serving everything else.

Restart-on-crash was considered and rejected: an actor that crashes has already lost the state it owned, and restarting it produces a subsystem that answers requests with a plausible but empty view. A capability that is honestly absent is easier to operate than one that quietly lies.

Tools and confinement​

Tools are resolved at startup, not at first call, so a missing or misconfigured tool is a startup error rather than a failure in the middle of a run.

The native tool set covers shell execution, path-confined file reads and writes, Python execution in a per-agent virtual environment, HTTP fetch, web search, and memory search and write. File tools are confined to a resolved root: the path is canonicalized and re-checked against the root after resolution, so a symlink cannot walk out of it.

The root of an agent's file tools is the union of the folders granted to that agent, the built-in chat agent included, on the chat path and on the task path alike. A folder of the machine is a connection, chosen with the system picker, and its tools are granted to an agent one by one: the reading tools reach every folder granted to it, the writing tools only those granted for writing. The first folder granted, by the date it was added, anchors a relative path. An agent with no folder granted has file tools that refuse with a message saying how to grant one. Nothing else is a root: not the home folder, not the data directory, not the volume, not the folder of a project. The former [filesystem] trusted_paths and [chat] default_workspace keys are withdrawn; the folders they named, the home folder excepted, were added once as connections, granted to no agent.

Confinement is not uniform across platforms, and the difference is not cosmetic. On Linux, shell execution runs under PID and mount namespaces. On macOS the confinement is weaker. On Windows there is none. This is stated plainly rather than smoothed over, because an operator choosing a platform is choosing a threat model. See the agent trust model for what that means in practice.

Outbound HTTP​

Every outbound HTTP call goes through one module, apollia_core::net, and scripts/check_http_clients.py refuses a client built anywhere else or a response body consumed by any other means.

The module carries two rules. A destination must resolve outside the loopback, private, link-local, multicast and unique-local ranges, and outside the internal domain suffixes, checked on the first URL and again on every redirect hop, so a public URL cannot redirect into the local network. A response body is read through a cap that abandons the stream once the budget is crossed, rather than buffering the whole answer and measuring it afterwards, by which point the memory is already spent.

One exception is named rather than left implicit. A destination the operator configured may legitimately be internal: a local MCP server, the runner, a self-hosted model endpoint. Those clients are built through a separate constructor that keeps the body cap and drops the address check.

Holding both rules in apollia-core is what puts reqwest and url in the crate every other crate depends on. They sit behind an optional net feature. Some of the crates that depend on apollia-core turn it on; the others keep the dependency closure they had, and neither crate is new to the tree: both were already workspace dependencies, and the lockfile gained no package. The cost is that the foundation crate is no longer dependency-free in every configuration. That is worth paying, because the address policy previously existed in two copies and the capped read in six, and each rule was simply absent wherever its copy had not been made.

Permission model​

A tool call of an agent is decided in a fixed order, the same in a conversation, in a task, in a step of a plan and in an item of a batch. The organisation policy is read first, then the fine rules, which are evaluated per invocation against this call's own first argument: a matching deny refuses the call outright. A tool whose family only reads then runs. Anything sensitive runs on a standing consent of the agent for the family of the tool, or on a grant given for the run. Anything left raises an approval request and waits for a person, who answers once, always or refuse; a refusal runs nothing, and neither does a request left five minutes without an answer in a conversation.

A fine rule carries one of two scopes: one agent, or every agent. A standing consent belongs to one agent and one family of action. Nothing is scoped to a project or to a session; what lasts as long as a run is the grant an answer gives to that run.

The cost is coarseness, and it is chosen: "always" covers a family, not a tool, so the person answers a question they can keep in mind (may this agent write files) rather than one per tool name, and a tool granted later in a family already consented to runs unasked.

Code executors are never covered by a standing consent. Consenting to a family is usually a convenience decision about reading or writing files, and it must not silently become permission to run arbitrary code.

A rule only lets a shell command skip approval in developer mode, and when that command is a single simple command, with no chaining, pipe, redirection, substitution, or backgrounding. That guard decides whether a call may skip approval; it is not a filter placed in front of execution, and a code executor with no matching rule asks a person on every invocation.

The permission decision is written to the signed journal. An answer to a consent prompt (once, always for this agent, or refuse), a refusal by the organisation policy and a refusal by a rule each become an entry, which names the agent, the tool and its family of action and never carries the argument of the call. The permission_audit table an older governance.db carries was never written to: the file loses it the first time it is opened.

Human in the loop​

Any tool can require human approval before it executes. When one does, the runtime suspends the run, emits an event carrying what is being asked, and resumes on the answer. Suspension is a first-class state, not a blocking wait: the process is free, and a run can stay parked across a restart.

A workflow asks the user a question from inside a step, through ctx.user.ask: one question, the choices it proposes when it has some, and a free answer the user may always give instead. The question is listed with the other decisions waiting for the user, with the run, the workflow and the step it comes from, and the answer wakes the step where it stopped. This wait is not a parked state: a workflow runs in memory and does not resume, so a run that waits on a question when the runtime stops is failed at the next start, with that reason, like every interrupted run. A declined question reaches the step as QuestionDeclined, which the author may catch.

Memory​

Memory has three layers: episodic events with an importance score, semantic facts with a confidence score, and procedures with triggers. Each agent has its own namespaced store, searchable full-text through SQLite FTS5 with BM25 ranking.

Memory is read at the agent's initiative. The runtime never injects memory content into an agent's prompt. An agent that wants context asks for it, which keeps the prompt something the agent author controls and can reason about.

Memory is written at the agent's initiative too. An agent without code keeps a note with the tool memory_write and finds it again with memory_search; nothing is written on its behalf. Checking Memory on the sheet of an agent is the one thing that opens both tools, whatever its access lists, and neither asks for consent: the space is the agent's own, opened by the person, and it reaches neither their data nor the machine. That space is named after the agent and kept across its conversations and its tasks: a space per conversation would let no note outlive the conversation that wrote it. The two tools are kept out of the family that reads personal data, because a consent given for that family also covers mail and calendars.

The profile is the opposite case. It follows the person into every conversation it is switched on for, so an agent never writes it. An agent whose profile is checked may propose one fact with profile_propose; the call is put to the person each time, with the fact shown, and no standing consent or grant of a run covers it. An accepted fact is kept among the facts of the profile with the agent as its author. The fields of the profile form are not facts and are refused.

Three exceptions exist, and none of them is reachable from an agent execution path. Two are inside the built-in conversational assistant: a brief of the profile of the conversation, and past session summaries on the first message of a free chat. The third is outside the assistant, in the desktop prompt-rewrite command, which carries the role, the function and the tools of that profile into its own one-shot prompt and returns text to the composer rather than to a run.

The profile brief is appended on every turn of the assistant, at every autonomy tier, while the profile switch of the conversation is on. The switch is on by default, stored with the conversation, and the conversation gauge reports whether the prompt carries the profile. The brief was previously gated by the longest autonomy tier, which free chat never runs at, so a profile the user filled in never reached the assistant. A setting with no effect is worse than an injection the user sees and can turn off, so the gate moved from the tier to the conversation. The rule for agents is unchanged: an installed agent reads the profile only when it asks for it.

Local inference​

Local models run through an embedded llama-server, the upstream llama.cpp server, which the daemon spawns and supervises over its OpenAI-compatible HTTP API, with native tool calling and continuous batching.

An in-tree binding was maintained previously and has been dropped. Keeping one meant tracking a fast-moving upstream through a foreign-function layer, and every capability that landed upstream landed here late, or not at all. Speaking the upstream HTTP API instead costs one local process and buys the upstream's release cadence.

A packaged build stages the engine binary. A source build expects llama-server on the PATH. When a local backend is configured and no engine is reachable, calls fail with an explicit unavailable reason rather than silently falling back to a cloud provider.

On macOS Apple Silicon, models in the MLX format run through a second embedded engine, vllm-mlx (Apache 2.0), an OpenAI-compatible server built on MLX with continuous batching, prefix caching and native structured output. One version is pinned, and the engine is installed by hash into an environment of its own inside the bundled Python tree, apart from the packages agents import: the engine starts with the bundled interpreter in a mode that keeps the agents' packages off its path. Only the dependencies that serving a model imports are installed, without resolving the rest of the closure the engine declares. Two exclusions follow from that. mlx-embeddings is GPL-3.0 and only backs the engine's embeddings endpoint, so it never ships and that endpoint is not served; the build and the release check both refuse a bundle that carries it. PyTorch, OpenCV, Gradio, SciPy and the audio stack are not imported to serve a model, so they are left out. The daemon supervises the engine the way it supervises llama-server, behind an engine interface that keeps the engine replaceable, and it listens on the loopback interface only. The cost is weight: the engine and its dependencies add about 150 MB to the macOS disk image, and they need macOS 14 or later, the floor of the MLX wheels at the pinned version. The Windows and Linux bundles do not carry it.

Engines have an update channel of their own and do not follow the version of Apollia. The embedded engine serves every machine from the first launch; an accelerated build, CUDA on a machine with an NVIDIA card, is downloaded on demand, or imported from a disk without network, and the launcher then takes the newest installed build of that variant over the embedded one. Which engine serves each model format is a setting, automatic by default; a chosen variant that is no longer installed falls back to the automatic choice. The followed channel is checked when the runtime starts and when the channel changes, and an update it announces for a chosen engine is installed unasked, unless the setting that governs it is off, in which case the update is only announced. Every download or import of an engine is preceded by the verification of a digest manifest signed in the minisign format with a dedicated distribution key. Each channel, stable or preview, publishes one such manifest, listing every engine archive with its SHA-256. The public half of that key is compiled into the binary and is the only key accepted. No archive is requested before the signature holds, an archive whose digest differs from the manifest is deleted, and a manifest older than the last one accepted on its channel is refused. A mirror can replace the address manifests and archives are fetched from; it cannot replace the key. Engine archives are read as zip only, the format already read for agent packages, and no other archive format is parsed. Upstream builds published as .tar.gz, those for macOS and Linux among them, are repackaged as zip by the release pipeline that publishes the manifest, and the manifest carries the digest of that zip. The MLX engine environment is listed in the same manifest and installed through the same verification.

There is no dedicated cloud provider. A remote model is reached through one generic connection to an OpenAI-compatible server, at the address the organization designates: a self-hosted vLLM or LM Studio, or a gateway. Any OpenAI-compatible address is accepted, but a cloud API is neither tested nor promoted. Where a cloud model is needed, LiteLLM is the recommended gateway: it runs on infrastructure the organization controls and speaks the OpenAI protocol to Apollia. When system.db is migrated, backends that pointed at the OpenAI, Anthropic or Mistral APIs are deleted, and backends that point at a self-hosted server are kept unchanged.

Retrying a failed answer​

A server error from an inference engine is retried for one minute at most, with an exponential delay, and then reported. The HTTP client of the OpenAI-compatible backends, async-openai, retries every 5xx for fifteen minutes by default, and an engine that refuses a request for what it is (a chat template that rejects the shape of the conversation answers 500 every time) held a batch item for a quarter of an hour before it failed. Bounding that retry means handing the client a policy of the backoff crate, the one it retries with, so backoff 0.4 is declared in the manifest of apollia-llm with no feature, under the cloud feature that already brings async-openai. It adds no code to the build: the crate was already in the lock file through async-openai. It is flagged unmaintained (RUSTSEC-2025-0012, and RUSTSEC-2024-0384 for instant under it), exempted in deny.toml for as long as async-openai retries with it; the day the client moves off it, the declaration goes with it.

Checking a downloaded model​

Every file of a model downloaded from HuggingFace is checked against the identifier the repository publishes for it before the model is recorded. A file stored through LFS (weights, projector, large tokenizer) is checked against the SHA-256 of its content. A small file committed outside LFS (config.json, tokenizer_config.json and the like in an MLX repository) has no published SHA-256: HuggingFace publishes only its git blob identifier, the SHA-1 of blob <size>\0 followed by the content. Such a file is checked against that identifier, which is why sha1 is a direct dependency of apollia-llm. It comes from the same RustCrypto family as sha2, adds no transitive crate, and serves to compare a file with what its repository declares, not to resist a forger: the TLS connection to the repository carries that guarantee, and a file that matches neither identifier is deleted.

The model registry​

The catalogue of models reads one online index of model cards. A card is a short text written for this project, in French and in English: what the model is good at, the capabilities its chat template declares, the memory it asks for, the sampling values its publisher recommends, the switches its template exposes, its licence and the page of its original repository. Every value a card repeats from a model repository cites the address it was read from; no README text is copied into a card, and a README is read from the repository only when the user asks for it, as text, never written to disk. The cards are written in this repository, so that a card is added or corrected without a new version of the application.

The registry is read under the local-first principle. The index is a static file, requested when the catalogue opens and at no other time, by a request that carries no body, no query, no cookie and no identifier: the registry learns nothing about who reads it, and no user data is sent. It is signed in the minisign format by the dedicated distribution key described under Signatures, whose public half is compiled into the binary and is the only key accepted; an index whose signature does not hold is refused, never used. The index is served from object storage operated in France, behind a domain of the project, with access logs kept for a short period only. When that address does not answer, the same signed index is read from the release mirror on GitHub, under the same verification. The last index accepted is kept on disk, and the card of a model is copied next to it when it is downloaded, so an installed model reads its card offline.

An organisation replaces the address by an internal mirror, or turns the registry off, through the [models] section of apollia.toml. A mirror changes where the index comes from, never who may sign it.

The index built from the cards when the application was made ships inside the binary. The catalogue reads it when no address answers and no signed copy is kept, and in a build that carries no distribution key: a first launch without network shows every card the build knew. It carries no signature because it needs none, being part of the binary whose own signature covers it, and it never replaces a signed index: an index read from an address or from the kept copy wins over it, under the verification above. A registry turned off by configuration or by policy stays off, and the shipped index is not read either. A guard fails when the shipped index no longer matches the cards.

The cost is that the cards of a machine that never reaches the registry are as old as its build.

A publisher is shown with the picture of its organisation on HuggingFace. No picture ships with the application: each one is asked when the catalogue opens, by a request that names the organisation and nothing else, accepted only from that host over HTTPS, in a raster format and under a size cap, then kept in the data directory, so that it is asked once per machine. A publisher whose picture could not be read is shown by its initials. A registry turned off asks for no picture.

Speech to text​

Transcription runs out of process, in a sidecar built on whisper, so a model crash cannot take the daemon with it. It is optional: a build without it loses dictation and nothing else.

Model Context Protocol​

Apollia is an MCP client over stdio and HTTP transports. It is also an MCP server, for the agents and workflows the user chooses and nothing else: apollia-os mcp server is a stdio bridge to the running daemon, which lists the agents and workflows marked exposed and runs one to its end when a client calls it. An agent takes one message, under the tool name agent__<id>; a workflow takes the inputs its schema declares, under workflow__<package>_<workflow>. A name keeps to [A-Za-z0-9_-] and 64 characters, as the model APIs require, and two items whose names would collide are both left out. The bridge is off by default: the daemon refuses it until [mcp] server_enabled is turned on, a setting it reads again each time a bridge opens. The native tools (shell, files, agent installation) are no longer served to an MCP client.

Every MCP response is treated as untrusted input. Responses are capped per server, tool names and descriptions are validated at ingestion against a character set and a length limit, and the number of tools a single server may contribute is bounded. A server that misbehaves degrades itself, not the runtime.

Servers requiring OAuth go through a dedicated flow with its own, smaller response cap.

Node.js is not shipped with Apollia, which keeps principle 2 (zero external dependency): nothing has to be installed on the machine beforehand. A pinned LTS release of Node.js is published in the signed digest manifest of the engine channel, the manifest signed in the minisign format with Apollia's dedicated distribution key (see Local inference), and it is downloaded the first time a server launched by node or npx needs it: the manifest signature is verified before any archive is requested, then the archive digest, then the archive is extracted into the engine store. Such a server then runs on that Node.js by its absolute path, never through the PATH; while none is installed, its launch fails with a typed "Node.js required" error. A machine without network imports Node.js from a disk, through the same verification. The npm packages of the servers install from a lockfile carrying an integrity digest for each package.

Connectors​

Google and Microsoft connectors authenticate through OAuth2 with PKCE. No aggregator sits in the path: a paid third-party relay between an operator and their own mailbox contradicts the point of the product.

The binary embeds a Microsoft OAuth client and no Google one. Google's restricted scopes require a verification process the project has not completed, so a Google connection asks the operator to supply their own client credentials. The asymmetry is deliberate, and it is stated where an operator meets it.

Documents and sources​

Workbooks and CSV files are read in Rust, without the embedded Python interpreter. calamine (MIT) reads .xlsx, .xlsm, .xlsb, .xls and .ods; csv (MIT or Unlicense) reads CSV files, with the separator detected among the comma, the semicolon and the tabulation. Both are pure Rust, read only, link no system library and open no connection. Each cell is read as text with its sheet, row and column: a number without a zero fraction, a date in ISO 8601, a formula by the value the file last computed for it (no formula is evaluated), an error by its code. A workbook protected by a password, or damaged, is a typed error, never a crash. A CSV file that is not UTF-8 is read with its invalid bytes replaced, and the result says so: no encoding is guessed.

The cost is named: calamine depends on zip 8 while the workspace carries zip 4 for the package archive and the batch export, so two major versions of zip enter every build, with five small crates under calamine (atoi_simd, codepage, debug_unsafe, fast-float2, typed-path) and csv-core under csv. calamine is pinned to 0.36.1, so that a new release, and the version of zip it brings, is a decision rather than an update.

The text of a PDF document is extracted in Rust by pdf_oxide (MIT or Apache-2.0), page by page, each page with its number. It is pure Rust, reads only, links no system library and opens no connection; its default features are off, so neither its colour-profile engine nor its rendering, OCR or signature modules enter the build, and only legacy-crypto is kept, so that a document locked with an owner password alone still opens. A page that draws no letter and no digit is marked as a page without text, the page that optical character recognition has to read. A document that needs a password to be opened, or a damaged one, is a typed error, and a defect of the parser on a malformed file is caught and reported as a damaged file, never a crash. It was measured before it was adopted, on twenty real French documents published under the Licence Ouverte (contract documents and terms of use, budgets and price lists, methodological reports), with five accented witness phrases per document taken from an independent extractor: nineteen documents of twenty yield all five phrases with every accent intact; the twentieth loses one phrase whose words a table cell spreads over two lines. The corpus is versioned with the tests that read it, and PDFium, the native fallback considered, is not shipped.

The cost is named: pdf_oxide brings fifty-three crate versions the lock file did not hold, among them a font stack (skrifa, read-fonts, write-fonts, ttf-parser, subsetter), a layout engine (taffy) and second majors of quick-xml, brotli, sha2 and aes. pdf_oxide is pinned to 0.3.78, and office_oxide, which it depends on, is held at 0.1.11 in the manifest of the workspace and in the lock file: its 0.1.12 release no longer compiles under pdf_oxide 0.3.78, so an update of either is a decision rather than a refresh. The manifest holds it because a build that resolves without the lock file would otherwise take the newer release.

One crate of that font stack carries an advisory: ttf-parser 0.25.1, which pdf_oxide alone uses to parse the fonts a document embeds, is flagged unmaintained (RUSTSEC-2026-0192). No vulnerability is known in it and no newer release exists to move to, so the advisory is exempted in deny.toml, dated 2026-10-04, with that reason beside it, and it is revisited at the next minor release. It lifts when pdf_oxide drops ttf-parser or a maintained release of it appears.

Word documents and PowerPoint presentations are read in Rust, by code of our own over two crates: zip, the one that opens the package archive (see Package archive), opens the file, and quick-xml (MIT), a streaming XML reader, reads its parts. A Word document yields the paragraphs and the table rows of its body, one row per segment, in the order of the document and each with its rank; a presentation yields the text and the speaker notes of each slide, slides in numeric order, each with its number. Headers, footers, comments and floating text boxes are not read. A file in the legacy binary formats, .doc or .ppt, is a typed error naming the formats that are read. The archive comes from elsewhere, so reading it is bounded: more than 10,000 entries, or more than 64 MiB decompressed across the parts read, and the file is refused, the bytes being counted as they are inflated rather than as the archive declares them. A dedicated crate was not taken: docx-rs, the one weighed for Word, is oriented to writing and does not read PowerPoint, and office_oxide, which the PDF reader brings, is held at an old release (see above) and is not called directly.

The cost is named: no crate enters the lock file. quick-xml 0.41 was already built for the spreadsheet reader and the desktop bundle, and is now a direct dependency with its default features off; zip was already a direct dependency. What these crates do not cover is ours to maintain: the structure of the two formats, read here only as far as their text.

Scanned pages and pictures are read by optical character recognition, with an engine compiled into the binary. A page of a PDF document that draws no text and is a single picture is taken as the document embeds it, without rendering the page, and recognised; the .png, .jpg and .tiff files of a source are read the same way. The engine is Tesseract, over Leptonica, both built from source by tesseract-rs (MIT; Tesseract is Apache-2.0, Leptonica BSD-2-Clause) and linked statically: the machine that runs Apollia installs nothing, and the binary depends on no Tesseract or Leptonica library. Leptonica is built without its image codecs, so pictures are decoded in Rust by image (MIT or Apache-2.0), with its PNG, JPEG and TIFF decoders only. The recognised text lives in the index of the source and nowhere else: no file is written beside the documents.

The engine reads with one data file per language. French and English, from the tessdata_fast set (Apache-2.0), are entries of the model registry, each pinned by its SHA-256. They are fetched the first time a source holds a scanned page, once per run, by the downloader that checks every model against its digest; a machine without the network takes the same files from a disk through the model import, which accepts a language data file only when its digest is a pinned one. A file whose digest is not pinned is never handed to the engine. Until the data is there, each page that waits is listed in the state of its source as waiting for recognition, and is read once it arrives; a page without text that is not a single picture is listed as not read. No rendering engine is shipped: hayro and PDFium, the ones weighed, stay out until documents show the need.

The cost is named: the C++ toolchain enters the build. Compiling the engine needs cmake, a C++ compiler and the network, takes one to two minutes (83 seconds on macOS Apple Silicon, 133 seconds on Linux x64 under emulation), and adds 2.8 MB to the binary on macOS and 4.4 MB on Linux. The build script of tesseract-rs downloads the sources of Tesseract 5.5.2 and Leptonica 1.87.0 from GitHub without checking a digest, keeps them with the compiled libraries in a folder of the home directory rather than under target/, and fetches two language files there that the product does not read. Recognition therefore sits behind the ocr feature, off by default: an everyday build compiles none of it, and a build that ships recognition turns it on. tesseract-rs is pinned to 0.4.0, since a release changes the sources it fetches; three crates enter the lock file, tesseract-rs itself and aws-lc-rs with aws-lc-sys, which the HTTP client of its build script brings and which run at build time only. image was already built for the PDF reader and adds none. The static build is measured on macOS and on Linux; it is not measured on Windows, where the feature stays off until it is.

The results of a batch are written back to the table it read, by default into a copy next to the original, <name> (results YYYY-MM-DD).xlsx, named in the language of the interface, so that the original is read and never opened for writing. On explicit request, and for .xlsx and .xlsm only, they go into the original itself, after the dated backup copy the batch contract requires; when the backup fails, nothing is written. In both cases the workbook is edited in place, by code of our own over zip and quick-xml: the cells of the added column, after the last column of the sheet, are inserted into the XML of that sheet as inline strings, the calculation chain is removed so that the spreadsheet recomputes on opening, and every other part of the archive is copied as it is stored, byte for byte. Formatting, charts, pivot tables, comments and macros are therefore kept, which a library that loads a workbook and saves it again does not guarantee: umya-spreadsheet, the one weighed, documents charts corrupted, comments lost and pivot tables not read. The edit is tested against a versioned corpus of real workbooks (conditional formatting, a chart, pivot tables, comments, a file saved by LibreOffice, a workbook with macros). A CSV file gets a copy with its column added, never an edit of the original; a .xls, .xlsb or .ods workbook gets a new .xlsx copy written by rust_xlsxwriter (see Batch export), its values only.

The cost is named: no crate enters the lock file, rust_xlsxwriter becoming a direct dependency of the readers. What is ours to maintain is the edit of the sheet, a small part of a format whose other parts are never rewritten.

A mailbox connects over IMAP with an application password, so any provider is reachable, Gmail included, without an OAuth client and without a relay. The client is async-imap (MIT or Apache-2.0), on the Tokio runtime, over the tokio-rustls TLS stack the runtime already carries; it brings no OpenSSL. Messages are decoded by mail-parser (Apache-2.0 or MIT), which has no dependency outside Rust. The server certificate is checked against the root certificates of the system, read by rustls-native-certs (Apache-2.0, ISC or MIT), already present in the build through the HTTP client and now a direct dependency.

The access is read-only, and it is enforced in the client rather than promised: folders open with EXAMINE, never SELECT, messages are fetched with BODY.PEEK so they stay unread, and no command that writes (STORE, EXPUNGE, COPY, MOVE, APPEND) is ever sent. Sending, moving or deleting mail is out of reach. The password lives in the configured secret store, never in the connection list, a log or the journal; because async-imap traces the commands it sends through the log facade, a login included, every binary turns its log target off.

The cost is named: two crates and their small trees (imap-proto, hashify, stop-token, async-channel) enter every build, for a protocol whose servers vary in how strictly they follow it. A server that misreads a command answers a typed protocol error rather than a crash.

Batch export​

The result of a batch is exported as an Excel workbook, as CSV or as JSON, one row per item. CSV and JSON are written without a dependency. The workbook is written by rust_xlsxwriter (MIT or Apache-2.0), a writer only, with no network access, pinned to 0.92.0: it is the last release that depends on the major version of zip the workspace already carries, and a later one would put two versions of zip in every build. It adds three crates to the lock file, itself, zopfli and zlib-rs (see Package archive).

rust_xlsxwriter cannot change a workbook that already exists. Writing results back into the file a batch was read from is decided with the reader of spreadsheets (see Documents and sources).

The desktop application writes one more workbook with it: the demonstration dataset an example agent can be tried on, built from a table the application carries when the user asks for a copy. No crate enters the lock file for it, rust_xlsxwriter becoming a direct dependency of the desktop.

Agent-to-agent messaging​

One agent reaches another in two ways. A workflow step runs an installed agent on a message and waits for its answer: the agent runs under its own grants, nested in the run that called it. Or a message is posted to the durable mailbox of an agent, backed by SQLite, and the recipient pulls it at its own initiative. Mailbox delivery is leased: a consumer takes a lease, and acknowledgement is fenced on the run holding it, so a stale consumer whose lease was reassigned cannot delete or requeue a message the new owner is processing.

The API lists what can be called under a full identifier, agent:<id> for an agent and workflow:<package>/<name> for a workflow, and launches either by it. Dispatch propagates that identifier whole. A shorter dispatch key was used once and produced ambiguity as soon as two workers exposed similarly named skills.

Audit and evidence​

There is one register: the hash-chained journal.

Its entries are chained and signed, and the chain is anchored globally so that truncation is detectable: removing the tail of the journal breaks a verification a reader can run themselves. A tool call is two entries, one when it starts and one when it ends, and a call that failed is recorded as failed. An entry is appended without blocking execution, and it is dropped, with a warning in the logs, when the channel is saturated: the journal proves that what it holds was not altered, not that it holds everything.

The journal records a run through the events the run emits. A tool call made in a conversation is announced with its run and is therefore recorded; a call no event announces with a run is not.

A captured run can be replayed against its own record. apollia-os audit replay and POST /api/v1/audit/replay/{run_id} step through what the journal captured for the run, each model response followed by the outputs of the tool calls it asked for, with no model call and no tool execution, and report identical, diverged or an incomplete trace. Running the agent a second time and comparing the two runs is deliberately not built: the journal does not hold the prompt the run started from, and a second run proves how the second run behaved, not the first. Accountability rests on the signed chain, and the cost is that a replay says whether a capture is coherent, not whether the agent would act the same way again.

Secrets and API authentication​

Secrets live in the OS keychain, or in an age-encrypted file where no keychain exists. They are never written to configuration files.

The HTTP API listens on a Unix socket and on a TCP port; the daemon binds both on every start, and the port number is the only thing --port decides. The Unix socket is local-trust and relies on filesystem permissions. TCP requires a bearer token on every /api/v1 path but one, and binding it to a non-loopback address without a token is a startup error rather than a warning: an unauthenticated remote bind is the one mistake that cannot be undone once traffic has flowed. TLS is optional: with [api] tls_cert and tls_key set, the TCP listener serves HTTPS, and a certificate that cannot be loaded fails the start instead of falling back to cleartext.

The exemption is GET /api/v1/health, which answers without a token and reveals the product marker and the runtime version, nothing else. A process that finds the port taken holds no token of this installation, and what it needs to know is whether an Apollia runtime is the holder and in which version. Every other /api/v1 route keeps the token.

The compatible server and the local network​

The runtime has two listeners. The native API, port 7771 and the local socket, drives the runtime; the compatible server, port 7772 by default, serves the OpenAI and Anthropic formats to other applications. The native API stays on the machine: the embedded runtime of the desktop binds it to 127.0.0.1 whatever the file says, the daemon to the loopback [api] bind names by default (another address needs a token, as above), and no setting of the compatible server moves it. The compatible server is the only surface the user opens to the local network, by [server] serve_on_lan, which is off by default and turned on only by an explicit action of the user.

The network and the tokens are independent switches for models. A device of the local network can call the models without a token when [server] require_auth is off, as other local inference servers allow; that choice belongs to the user. Agents and workflows are not models: they reach the user's connections, documents and mail, so a call to one from the local network is admitted only with a token of the server in use, whatever require_auth says. Each request carries its origin, the machine or the local network, read from the peer address when its connection is accepted. The organisation policy is where an organisation forbids the local network or imposes tokens, overriding the user's switches.

Read against the local-first principle: nothing leaves the machine until the user opens the network, and what then answers without a token is inference on the prompt the caller sent itself. The user's data, which only agents and workflows reach, never answers the network without a token the user created and can revoke. The native API, which holds every other capability, is never reachable from the network through this switch. Traffic on the network is cleartext HTTP: the compatible server does not terminate TLS.

Signatures​

Two signature schemes coexist, one per kind of signer, and neither needs a network to verify.

Agent packages and organisation policies are signed by their author, with a key the application generates. The signature is a detached Ed25519 signature over a canonical list of (path, SHA-256) entries sorted by the bytes of the path, so the order in which files were collected never changes it, and adding, removing or altering a single file breaks it. It travels in a versioned SIGNATURE file that names the format version, the fingerprint of the key (the SHA-256 of the public key), the publisher name the author declared, and the signature. The private key is stored through the secret store and never written in clear to disk. The publisher name is a declaration and is not covered by the signature: what identifies an author is the key fingerprint.

Distribution metadata published by the Apollia release pipeline (the digest manifest of the inference engines and of Node.js, the model registry index) is signed in the minisign format, with a dedicated distribution key. It is the same format as the desktop updater key and a distinct key, held in its own pipeline secrets (APOLLIA_DIST_SIGNING_KEY, APOLLIA_DIST_SIGNING_KEY_PASSWORD). Signing happens in the pipeline with the Tauri signing tool; the application only verifies, and the daemon verifies without Tauri, which is why the verifier is a direct dependency rather than one reached through the updater plugin.

minisign was not taken for packages and policies: it signs one file, not a list of entries, and the key generation and storage the application needs would have to be written anyway. Sigstore and X.509 were rejected for both uses: one needs a transparency log and an identity provider reached over the network, the other a certificate authority, and either makes an outside party a condition of verification, against the local-first principle.

The cost is a new dependency surface and no revocation. ed25519-dalek 2 is compiled without default features (no precomputed tables), and brings ed25519 and signature; its curve arithmetic, curve25519-dalek, was already in the tree. minisign-verify has no dependency of its own and was already in the tree through the updater. A key cannot be revoked other than by changing the set of keys a reader trusts.

Organisation policy​

An organisation imposes its rules on a machine by dropping a signed policy in a system folder: /Library/Application Support/Apollia/policy on macOS, C:\ProgramData\Apollia\policy on Windows, /etc/apollia/policy on Linux. The folder holds policy.toml, a SIGNATURE in the package format above over a list of one entry, and org.pub, the organisation public key. The runtime reads it once, at startup. No folder means no policy: the user is free.

The key sits next to the policy, so the policy is only as authentic as the rights on its folder. A folder or file that someone other than the system administrator can modify is refused: on Unix the owner must be root and neither the others nor a group other than root's may write; on Windows the owner must be the Administrators group or the LocalSystem account, and no entry of the access list may grant a write to anyone else. A symbolic link is refused on every platform. The Windows path is written out rather than read from %ProgramData%, which the user sets.

The date of signature is a signed_at field of policy.toml, a date, a time and an offset. The signature covers the whole file, so the date is signed and the SIGNATURE format did not change for it. A file system date was rejected: anyone can touch a file.

Reading a Windows owner and access list takes the Win32 security API, which the standard library does not expose. windows-sys is a direct dependency of the permissions crate on Windows only, with the security, authorization and file system features. It is the raw binding Microsoft publishes, it has no dependency beyond its own linker shim, and the same version was already in the tree through other crates, so the lockfile gained no package. The cost is a block of FFI code with its own unsafe allowance, confined to one module.

The configuration file​

apollia.toml has one writer, apollia_core::config::writer. The settings screens of the desktop application, the command line, the routes under /api/v1/config and the backends the runtime mirrors into the file all go through it, so the same four checks hold whoever writes: the text validates against the strict schema, no line gives a key the organisation policy fixes another value, the file on disk is still the one the caller read, compared by its SHA-256 fingerprint, and the text replaces the file through a sibling file renamed over it. A write by key keeps the comments and the order of the file. The command line calls the writer directly, so it works with the daemon stopped, and a refusal reads the same sentence on every surface.

Holding the writer in the foundation crate puts toml, toml_edit and sha2 among the direct dependencies of apollia-core. All three were already workspace dependencies and the lockfile gained no package.

Command line​

The CLI is noun-verb. Daily operations are bare verbs; everything else is a noun carrying subcommands. --json is global and a terminal is auto-detected, so one command serves a human and a script.

Exit codes are a contract: 0 success, 1 usage error, 2 runtime error, 3 task failed, 4 timeout, 5 canceled. A caller branches on them without parsing output.

Desktop application​

The desktop app is Tauri with a Svelte frontend, sharing the same runtime and the same embedded Python interpreter as the CLI. It is a second front end over one runtime, not a second implementation.

All user-facing text goes through the internationalization layer with parallel English and French entries. Color, spacing, and typography come from design tokens; a hardcoded value duplicating a token is a value that will not follow a theme change.

The runtime in the window or beside it​

The runtime runs in one of two places: inside the desktop process, or as a separate daemon, apollia-os start installed as a service of the user, to which the desktop attaches. The daemon is what lets agents and scheduled work go on with the window closed, and it is the same runtime, not a second one.

The TCP port decides. At launch, before the Python interpreter, any database or any listener, the desktop binds the runtime port. Winning it means no runtime serves the machine: the desktop starts one in its own process, on that very listener, which is never released between the decision and the serve. Losing it means something holds the port, and the desktop asks it GET /api/v1/health, whose body carries a product marker and a version: a compatible Apollia runtime is attached to, anything else is refused. A probe followed by a bind was rejected, because the moment between the two belongs to whichever process takes the port first.

Refused: two runtimes on one machine, attaching to a listener that does not say it is Apollia or to a runtime whose major or minor version differs, and a launch that ends without a word. A refusal opens a window that names what holds the port and the gesture to make, and appends the reason to startup-refused.log in the data directory.

Attached, the desktop builds nothing of a runtime of its own. It reaches the daemon over the local HTTP API with the token of the data directory, and receives the whole event bus as one stream, GET /api/v1/events. It asks the health route every two seconds rather than assuming the daemon is there, so an unanswered probe shows the runtime as disconnected at once. A command without a route yet answers a typed error that names it, instead of reaching for state the process never built; scripts/check_attached_coverage.py counts the routed commands against the registered ones, on a descending ratchet. A command that belongs to the window rather than to the runtime (quitting, restarting, checking for an update, listing the microphones) needs no route: it is named in a list beside the routed ones, with the reason it reads no runtime state, and the same script refuses an entry without one. Quitting the attached desktop leaves the daemon serving.

Both modes are delivered.

One application, and a package file that opens it​

The .apo extension is associated with the desktop application (bundle.fileAssociations in tauri.conf.json), so a package opens the way a document does. Opening a package never installs it: whatever brings the file, the application shows the preview of the package, and the package is installed or updated only from that preview. Three gestures lead there: opening the file from the system, dropping it on the window, and the Install button, which opens a file chooser.

A file opened from the system has to reach the application that already runs, not start a second one beside it. macOS keeps one process per application on its own and delivers the file as an event, at launch and afterwards. Windows and Linux start a new process with the path among its arguments; there tauri-plugin-single-instance makes that second process hand its arguments to the first and exit, and the first shows its window. The plugin is registered before any other, in both runtime modes, and on Windows and Linux only: on macOS it would add a second mechanism where the system already provides one. The process keeps the paths until the interface takes them, since a file that starts the application arrives before the interface is loaded. A file dropped on the window comes through the drag-and-drop event of the webview, with no plugin.

The cost is one more Tauri plugin to follow, which brings no library the tree did not already hold, and a code path that differs between macOS and the two other systems, so each has to be checked on its own machine. On Windows and Linux a second launch can no longer open a second window.

Refused: a deep link (apollia://install), which does not answer a double-click on a file, and offering the button and the drop alone, which leaves a package unable to open like a document.

Agent distribution​

An agent travels in a package, one .apo file (see Package archive), and is installed from that file and from nothing else: opened from the system, dropped on the window, chosen with the Install button, or handed to apollia-os package install. Whatever brings it, the package is previewed first, with what it contains, who signed it and what each of its agents asks for, and nothing is written before the user accepts. An agent created in the application leaves it the same way: it is exported as a .apo whose manifest is written from the agent as it stands, with no secret, no memory and no history.

Publishing agents to a package index was considered and rejected: it would make the index an availability dependency of a product whose first promise is running without one.

Third-party Python dependencies declared by a package are installed into that package's own virtual environment, after explicit consent, and the user sees the list before it happens.

The cost is one step for an author: a folder or a repository is not installed as it is. The archive is built first (apollia-os package build), which is where the manifest is validated and where the package is signed.

Package archive​

An agent package travels as one .apo file: a zip archive with the apollia.toml manifest at its root, the Python modules and resources of the package at their relative paths, and, when the author signed it, a SIGNATURE file (see Signatures) and a SIGNATURE.key file holding the public key that made it. The signature covers every other entry. Building the archive validates the manifest and refuses a folder whose modules define tools or workflows that the manifest does not describe; it runs no Python.

Zip was taken over a tar.gz: any operating system opens it without a tool, and its central directory lets the manifest be read without decompressing the rest, which is what an install preview needs. A tar.gz has no index, so reading one file means decompressing everything before it, and it would have cost the same two direct dependencies (tar and flate2) for none of that. Reading zip with code of our own was rejected: an archive parser is an attack surface, and an unaudited one is a worse surface than a maintained one.

An archive comes from elsewhere, so reading it is bounded. An entry with an absolute path, a .. component, a backslash or a drive prefix is refused, as is a symbolic link and a name that appears twice. The archive is capped at 256 MiB, 4096 entries, 64 MiB per entry decompressed and 512 MiB decompressed in all, and the manifest at 1 MiB. The caps apply to the bytes actually decompressed, not to the sizes the archive declares, so a lying header does not lift them. A package over any cap is refused at build time too, so what Apollia builds it can read.

The cost is two direct dependencies of the runtime, zip and flate2, both already in the tree through the desktop updater; no crate entered the lock file. zip is compiled with deflate only: no encryption, no bzip2, lzma, zstd or deflate64. flate2 is declared beside it for one reason, to choose the pure-Rust backend (miniz_oxide) of that deflate: zip leaves the backend to the caller, and flate2 refuses to compile without one.

The Excel export of a batch (see Batch export) changed one part of this. Its writer turns on the deflate feature of zip, which brings two pure-Rust crates, zopfli (Apache-2.0) and zlib-rs (Zlib), and makes zlib-rs the deflate backend of flate2 across the build. Both were accepted rather than writing the workbook by hand over zip alone. The read bounds above are unchanged.

The packages Apollia ships inside its own binary (the in-app guide, the onboarding and the agent workshop) are not signed. The desktop assembles each of them on start from files embedded at compile time and installs it through the same path as any package. The installation knows them as embedded because the desktop says so, never because an archive says so: nothing inside a file can claim that origin, and the same archive handed over from elsewhere is a plain unsigned package. An embedded package installs with no notice about its signature, and an organisation policy that requires signed packages does not refuse it. The exemption is bounded to that origin. Its reason is that the binary carrying the package is what the distribution signature covers, so signing the package again would prove nothing more. Like any package, it is recorded in the audit journal, a newer installed version is never downgraded, and the user's overrides and the disabled state of its agents survive an update.

The example packages follow the same rule, with one difference in what happens after. They are declarative packages embedded in the runtime at compile time, from one folder of the source tree. The application proposes them and installs one only when the user chooses it, after the preview every package gets. An example is named to the runtime by its package name, never by a path, so only what the binary carries is treated as an example; it is unsigned, and neither flagged nor refused for it, for the reason above. Once installed it belongs to the user: unlike the guide, it is uninstalled like any package.

A package tool runs with a bare context: no memory, no user profile. One exception is decided by the host, never by a package. The onboarding of Apollia ships as a declarative agent with one tool that records the profile facts of the first conversation, the right the onboarding agent held when it ran code. The runtime grants that tool the user profile, writable, and the memory of the onboarding, which the desktop reads to learn that the onboarding is complete. The grant names the package, the module and the source the binary carries: a tool whose module on disk is not that source byte for byte, whatever its package is called, gets nothing.

Host integration​

The versioned HTTP API is a driving contract, not an implementation detail. It carries a generated OpenAPI schema and generated TypeScript and Python clients, so a host product drives the runtime without reverse-engineering route modules.

Breaking changes go through a new version prefix, never through a silent mutation of the current one.

Public error surface​

Every public error enum in the workspace carries #[non_exhaustive], and scripts/check_rust_rules.py refuses one that does not. Adding a variant to an error type is then an ordinary change rather than a breaking one for every crate that matches on it, which is what the attribute exists to absorb.

apollia-core is sealed like the rest, and it is the crate where the attribute earns the most: every other crate depends on it, so an unabsorbed variant there would break the whole workspace at once.

The cost lands on the matching side, and only across a crate boundary. A match on one of these enums from another crate needs a fallback arm, and the compiler stops asking that match to be exhaustive, so a variant added later reaches the fallback instead of failing the build. Three cross-crate matches were exhaustive when the attribute went on, all three mapping a runtime error to an HTTP status; each now answers an unmatched variant with a 500 carrying the error's own message rather than with a panic.

Platforms and release​

Linux, macOS, and Windows are supported targets. Tool confinement differs between them, as stated above.

Releases are published on GitHub Releases. The desktop app checks that feed only when the operator asks, never in the background, and reports an empty feed as an empty feed rather than as an error.

There is one installer per system. No installer exists in a second edition for one kind of graphics card: an edition per card made the reader choose a download from hardware they may not know, and tied the version of an engine to a release of the application. The engines an installer does not embed, the CUDA build of llama-server and the Node.js that runs tool servers, are published apart, on their own channel, under a manifest signed in the minisign format with a dedicated distribution key (see Local inference). The pipeline that publishes them is separate from the one that publishes the application and is started by hand: it takes upstream archives pinned by SHA-256, repackages each as zip, and signs the manifest with a key that signs nothing else, distinct from the key of the application updates. The registry of Apollia serves the manifest and the archives, and GitHub Releases mirrors them. An application installed from a former CUDA edition keeps updating: the update manifest it asks is still published, as a copy of the standard one, so it moves to the one installer of its system and gets the CUDA engine from the engine channel.

Verification​

Beyond the test suite, two classes of property are checked by machine rather than by review: concurrency interleavings of the actor algorithms, and the two cardinal invariants, the non-bypassable step budget and the mailbox lease fence, proven under bounded symbolic execution.

These run on a schedule rather than on every change, because they are slow. Each has an in-tree counterpart running with the ordinary test suite, so a regression surfaces at the usual moment and the scheduled job confirms it.

Documentation​

This site is the documentation, organized by what a reader is trying to do: tutorials to learn, how-to guides to accomplish, reference to look up, explanation to understand.

The command-line and SDK references are generated from the code and committed, with a check that fails when the committed pages drift from what the code would produce. A reference that can drift silently is worse than no reference.