Let's Talk
July 15, 2026Reading time: 7 min

Technical appendix — Part 6: The One-Worker Architecture (and the framework we didn't use)

The builder's-eye view behind the finale. The blog said the whole studio is one small system; this is what "one small system" means in code, where the genuinely-small part is, and the precise version of "we didn't use a framework" — which is not the same as "we didn't use libraries."

MurtazaMurtaza - Senior Product Engineer

The builder's-eye view behind the finale, the companion to "One Small System Runs Our Whole Studio." The blog said the whole studio is one small system; this is what "one small system" means in code, where the genuinely-small part is, and the precise version of "we didn't use a framework" (which is not the same as "we didn't use libraries"). Line counts are from the repo at the time of writing and drift as it grows, treat them as the shape, not the spec.


The whole system is one Worker

Everything the studio does runs inside a single Cloudflare Worker. The root index.ts is the entire deployable, and it exports exactly two entry points: fetch, for anything that arrives over HTTP, and scheduled, for anything the clock triggers. wrangler.jsonc names that one file as main and runs it with Node compatibility on. There is one production route, agents.crescentic.com, and a parallel dev environment pointed at agents-dev.crescentic.com. npm run deploy ships the whole team in one step.

The fetch handler is a short switchboard: /webhook/github for GitHub events, /oauth/google/callback for the calendar OAuth dance, / and /health for liveness, and everything else falls through to the Slack app. No per-agent service, no gateway, no message bus. One process, a handful of doors.

Five small pieces, not a framework

An agent runtime, stripped to what it actually needs, is five parts: normalize an incoming event, route it to the right agent, execute that agent's loop, schedule the recurring jobs, and share memory across all of it. That is the entire list. Each part is small on its own:

  • Event normalization — src/runtime/event-normalizer.ts, ~74 lines.
  • Routing — src/runtime/agent-router.ts, ~62 lines.
  • Execution — src/runtime/agent-executor.ts, ~158 lines.

That is a routing-and-execution core of roughly 294 lines. Scheduling lives in index.ts's scheduled handler; shared memory is Postgres plus a context builder. The blog's "few hundred lines" is this core, and it is the honest number, not the size of the whole repository, which is much larger and mostly consists of the unglamorous work of talking to Slack (src/channel/slack-adapter.ts alone is around 1,700 lines) and to a database. The interesting part is small on purpose. The plumbing is large because plumbing always is.

Every channel becomes one event

The reason one small core can serve Slack commands, DMs, mentions, cron jobs, and GitHub webhooks is that none of the downstream code knows which of those it is handling. Every source is first converted into a single discriminated union, NormalizedEvent, with five variants today (slack_command, cron, slack_mention, slack_message, github_webhook). The normalizers are tiny pure functions, one per source, each of which just reshapes a raw payload into that common type and stamps it with a source and a timestamp.

This is what "channel-agnostic" actually buys you. Adding email or Teams or a web widget later is a new normalizer and nothing else; the router, the executor, and the memory layer never learn that a new channel exists. The agents are defined against the normalized event, not against Slack.

Routing that mostly costs nothing

Routing has a fast path and a slow path, and the fast path is free. A NormalizedEvent can carry an agentHint, and cron events and slash commands always do: a scheduled summary is Pulse's by definition, a /scout command is Scout's. When the hint is present and valid, the router returns it immediately with no model call at all. The CRON_AGENT_MAP in the normalizer is what lets scheduled jobs skip classification entirely.

Only free-text, someone typing a bare question into Slack, hits the slow path, where a small, cheap model classifies the message to one agent or to "needs clarification." That is the tenth-of-a-cent step from the cost part, seen from the architecture side. The expensive model never runs until the cheap one has decided it should.

The executor owns the loop

executeAgent is where an agent actually thinks. It takes the normalized event and an AgentConfig, builds the tool set, and runs the model's tool-use loop with a hard step ceiling: MAX_STEPS_COMMAND = 5 for interactive work, MAX_STEPS_CRON = 30 for scheduled jobs, overridable per agent. The agent's declared tools (AgentConfig.tools) are turned into the shape the model SDK expects right there in the executor.

Owning this loop is the thing the whole finale rests on. It is where tool errors are caught and replaced with an opaque string before they can re-enter the model's context. The prompt-injection guard from the safety part lives here, not in a library we cannot see into. It is where the step ceiling bounds cost and runaway behavior. It is where we decide what context an agent gets. Every hard problem in the series was tractable because this loop is ours to read and change.

The honest ledger: what we DO use

The claim is not "we wrote everything from scratch." It is narrower and worth stating exactly, because the obvious rebuttal, "but you use the AI SDK," is correct and misses the point.

The production dependencies are, in full: ai (the Vercel AI SDK, which runs the model-and-tool loop), @ai-sdk/anthropic and @ai-sdk/google (provider bindings), slack-cloudflare-workers (Slack transport), drizzle-orm and pg (database), and zod (validation). That is the list. A search of the codebase for langchain, crewai, openclaw, llamaindex, or autogen returns nothing.

So we lean on libraries heavily. What we skipped is the orchestration layer, the framework category that wants to own your control flow, your routing, your prompt templates, and your memory abstractions. Those are exactly the four things the sections above describe, and they are exactly the four things we wanted to be able to read line by line. This is, in the current build-vs-buy vocabulary, the hybrid path: rent the foundation model through a thin SDK, build the middleware that is specific to you. We rent the intelligence and own the decisions. The provider swap makes the rented part explicit: changing the whole studio from Anthropic to another provider is one import and one assignment in src/constants/model-ids.ts, because nothing above that line is welded to a vendor.

One more seam worth showing: the agent registry is a plain Map of three configs, and getAgentConfig throws for anything unregistered. That is why /admin errors today: it is planned but not registered. The failure is a visible three-line function, not a mystery inside someone else's dependency graph. That is the whole trade in miniature.

Why one Worker

Cloudflare Workers suited this for reasons that are boring and therefore reliable. It is cheap at our volume, it starts fast, and it runs at the edge without a server to babysit. Crucially, the platform gives us ctx.waitUntil, which lets a handler acknowledge Slack inside its three-second deadline and then keep doing real work after the response is sent. Nearly every interaction uses this: ACK immediately, do the slow model work in the background. Cron triggers are declared in wrangler.jsonc and dispatched by the same scheduled export, so the recurring jobs live in the same deployable as the interactive ones. Two environments, prod and dev, are two blocks in one config file. There is no orchestration to run because there is nothing to orchestrate, it is one program.

When you SHOULD reach for a framework

This post argues for a choice we made, not for a universal law, and the case has real edges. If you are prototyping and want something answering questions this afternoon, a framework will get you there faster than reading anyone's design notes. If you are a solo developer without the appetite to own infrastructure, the maintenance you save is worth the control you give up. If what you need is a standard retrieval-augmented chatbot, the shape a hundred other teams also need, then the frameworks have paved that road well, and repaving it yourself is vanity, not engineering.

We built our own because the work we automate is operations, and operations is specific by nature: our clients, our naming, our definition of "urgent," our Friday rituals. A generic abstraction flattens exactly the specificity that made automating it worthwhile, and it hides the loop at precisely the moments, a cost spike, a wrong answer, a leak, when we most need to see it. That calculus is ours. Run your own before you copy the answer.


We build small, specific systems like this one. Following along, or picturing one for your own operations? Say hi.

Back to the non-technical story.