The most important argument in my codebase is ws=None
Step 10 of my platform's headless launch path passes ws=None into the agent runner, with a comment that reads "No WebSocket — headless execution." That one argument is the whole strategy. The agent compiles, loads context, runs tools, streams events, meters cost, and finishes, and at no point does it check whether a browser is attached.
The function that owns this path is called execute_headless(). Its docstring calls it the single authority for headless agent dispatch, and it runs ten numbered steps: resolve the manifest, build the actor's security context, initialize the checkpointer, open the database sessions, build the runtime, compile, enter the tool context, build the initial state, build the config, launch. The WebSocket chat endpoint that serves our dashboard is a caller of the same compile path. It is not the owner of it.
I am writing this because the mid-2026 conversation about enterprise agents keeps happening at the wrong layer. The trade-press debate, CIO Dive and its peers, is mostly about which assistant sits in whose sidebar. Meanwhile Gartner keeps telling boards that a third of enterprise software will include agentic AI by 2028, up from under 1 percent in 2024. Both framings treat the surface as the interesting part. The substrate is the interesting part. The question that predicts whether your agents will matter is blunt: can your agent run, fully governed, with nobody watching?
One compiled agent, six callers
The platform is manifest-driven. An agent is a data file: its tools, its context slots, its prompt assembly, its graph strategy, its limits. Sixteen manifests are registered in the platform registry today, and the restaurant-analytics product this platform grew out of, at a large payments company, registers fifteen of its own. None of them know what a frontend is. Adding an agent is writing a manifest and registering it; the contributor doc bans agent-specific branches in the chat endpoint outright.
Here is who actually calls the compiled agents today, across production and CI. The first five callers are in the platform repo; the sixth is the origin product.
| Caller | Trigger | Human present? | Entry point |
|---|---|---|---|
| Dashboard chat (WebSocket) | A person types a message | Yes, watching tokens stream | WS endpoint, execution_mode "interactive" |
| Voice host bridge | A person speaks a turn | Yes, listening | In-process bridge into the same compile_and_run |
| Golden-test runner (CLI) | Engineer or CI invocation | No, asserting on output | SSE client against the backend, 76 test cases |
| Routine scheduler | 60-second database tick | No | execute_headless() from a claimed lease |
| Agent-spawned agents | A parent agent delegates work | No | execute_headless() with a delegation chain |
| Nightly insight workflow | Temporal schedule, post-sync | No | Batch ML-insights agent, Step 7 of the sync workflow |
The scheduler row deserves a sentence. Saved routines are claimed from the database with FOR UPDATE SKIP LOCKED leases, ten per batch, on a 60-second tick, and each claimed run goes through exactly the compile-and-launch pipeline the chat window uses. Same gates, same audit, same manifest. The only thing the platform records differently is a single typed field.
Governance lives in the compiler, not the client
The reason one backend can serve six callers is that nothing important happens in the client. Every launch, interactive or headless, passes through the same compile flow, and the compile flow is where the rules live.
Gate 1 checks whether this actor may run this agent at all; failure raises an access-denied error before anything is built. Gate 2 filters the tool catalog by the actor's permissions before the graph is compiled, and logs the arithmetic as N of M tools authorized. A tool the caller is not authorized for never becomes part of the agent. There is no runtime check to bypass, because the unauthorized capability does not exist in the compiled graph.
Runtime limits are compiled the same way. The recursion limit is set at launch to three times the manifest's max_iterations. Context budgets are enforced per model, 180,000 tokens for the large frontier tier, 90,000 for the small tier, 30,000 for local serving, so a long-running headless session trims its own history deterministically instead of dying on a provider overflow at 3 a.m. And when the actor is an agent rather than a person, the launcher injects an audit callback that counts every LLM call and tool call and logs an estimated cost to four decimal places when the task completes.
extra=RuntimeExtra(
organization_id=str(organization_id),
agent_type=manifest.agent_type,
# Explicit headless signal. Memory retrieval must not
# assume a human reader on the other end.
execution_mode="headless",
),That execution_mode field is typed as a Literal with exactly two values, "interactive" and "headless", defaulting to interactive. The chat endpoint never touches it. The headless entrypoint stamps it, twice, once on the compile-time runtime and once on the per-invocation extras, as a deliberate redundancy. Downstream systems change behavior off that one signal instead of guessing from circumstance.
Delegation gets the same treatment. When an agent spawns another agent, the child does not get the parent's keys by accident. The entrypoint signature carries the whole inheritance explicitly: a delegation depth for recursion limits, a serialized delegation chain for the audit trail, granted data scopes, the capabilities of whoever did the spawning, and the remaining dollar budget. Every one of those is an argument, which means every one of those is a decision someone made, recorded at the moment of dispatch. Headless does not mean ungoverned. In my platform, headless is the most governed path there is, because there is no human in the loop to catch what the compiler missed.
The 3 a.m. test
Here is where headless-first stops being an architecture preference and becomes the value proposition. In the restaurant-analytics product, insight generation does not wait for a question. It runs as Step 7 of the nightly sync workflow: after the data sync and the model retraining, a batch agent runs 5 ML and clustering skills for every location and saves the results for every user in the organization, deduplicated on a five-part idempotency key (run date, location, user, skill, source type) so a retry can never double-write. The workflow layer around it defines 32 Temporal workflows. Token cleanup fires daily at 07:00 UTC. Memory consolidation at 08:00. Model training, opportunity scouting, knowledge ingestion, all scheduled, none conversational.
Nobody is in a chat window at 3 a.m., and that is when most of the value gets produced. A person asks a question when they happen to think of one. A schedule asks every night, for every location, whether or not anyone logs in the next morning.
That changes the economics in a way worth saying in board language. When insights generate nightly for every location regardless of logins, the marginal cost of another beneficiary is zero, and per-seat pricing stops describing anything real. The unit that matters becomes cost per generated insight, which is dominated by metered model spend, and the idempotency key guarantees you never pay twice for the same one. Seat math measures access. Outcome math measures the thing you actually wanted.
The wrong turn: my pipeline lived inside a WebSocket handler
I did not start headless-first. The first version of this platform had the entire compile-to-launch flow written inline inside the WebSocket endpoint, and the headless entrypoint's own docstring still confesses it: the pipeline "follows ws_chat_v2.py lines 1488-1554 for the compile -> context -> launch flow." That is what extraction looks like in the fossil record. The agent runtime was an organ of the chat handler, and every new caller either duplicated those sixty-six lines of launch logic or did not get built.
The second assumption was worse because it was invisible. Memory retrieval assumed a human reader. Headless runs set the runtime user id to an agent identity instead of a human UUID, which broke the foreign-key contract on the memory read path. The fix cost a full work package: the explicit execution_mode field, a short-circuit to a retrieval path with no access logging and no share visibility, and a dedicated regression suite (test_memory_v2_1_headless.py) so it cannot silently come back. Two assumptions, and both were undetectable while the only caller was a chat window. That is my case in one sentence: a chat-only phase does not merely limit your surface. It hides coupling, and you pay to find it later.
Once the headless path exists, frontends get cheap
The payoff shows up in how little each new surface costs. The voice host arrived as an in-process bridge that persists turn lifecycle events and delegates each spoken turn to the same compile function; it added a transport, not a runtime. The CLI test runner is a few hundred lines of SSE client. The dashboard chat is one WebSocket handler governed by a hard rule in the contributor doc: no agent-specific if/elif branches in it, ever, because the endpoint is a transport, not a brain.
The reverse ordering does not work, and I know because I lived it. A chat-first runtime accretes assumptions the way mine did: a user id that is always human, a progress event that always has a socket, a memory read that always has a reader. Each one is a small tax on every future caller. Headless-first inverts the default. The interactive path becomes the special case that adds a socket, instead of the automated path being the special case that has to fake one.
There is a quieter benefit that took me longer to appreciate: testability. The 76-case golden suite exists because the agent can be driven without a browser. CI runs the full suite against the backend the same way the scheduler runs a routine, headlessly, in parallel, asserting on outputs. A platform whose agents can only be exercised by clicking through a UI gets tested the way UIs get tested, which is to say thinly and late. A headless agent gets tested the way services get tested. That difference compounds every release.
If a human has to watch, you automated nothing
The industry will spend the rest of 2026 arguing about agent UX, and most of it will be an argument about paint. My position: the chat window is a client, one of six in my case, and the least load-bearing of them. It is the demo surface and the debug surface. The value surface is a scheduler tick, a Temporal trigger, and a parent agent delegating to a child with a budget and a scope it cannot exceed.
So here is the test I would put to any enterprise AI platform, mine included. Unplug the frontend. If the agents stop producing value, you did not build automation. You built a very elaborate autocomplete with a meeting-friendly face. If your AI only works when a human is watching, you automated nothing. Build the ws=None path first, run your full governance stack through it, and let the chat UI be the second client you add, not the first.