Creating an Agent: What Happens After You Hit Save
From the outside, creating an agent looks like filling in a form. You describe what you want, you look at a preview, you click a button. Thirty seconds, maybe less. The interface is doing everything it can to feel like nothing much is happening.
That impression is the product working correctly and it is also completely wrong. The interesting engineering in an agent-creation path isn't the form — it's the handful of seconds after you confirm, where the system decides whether you're getting a working agent or a plausible-looking broken one. Those seconds are made of validation, versioning, and a few unglamorous atomicity decisions.
This is a walk through ours, in the order the system experiences it, and an argument about what a well-built creation path owes you.
Three doors
There are three shipped ways to get an agent in CatalEx. You can talk one into existence through a chat wizard — the "from scratch" path. You can pick from a template catalog of 159 templates across 13 categories, weighted toward the work people actually automate: 41 specialized, 30 marketing, 29 engineering, then a long tail through design, sales, testing, support, finance, and product. Or you can do nothing at all: every account is provisioned an agent at signup.
The three doors converge on the same write path. That's the first design decision worth naming — the wizard is not a privileged client. It ends up calling the same create contract as everything else, which means the guarantees below are guarantees for all three, not features of one nice UI.
The wizard refuses to let you one-shot it
The "from scratch" flow is a three-phase state machine:
type Phase = 'chatting' | 'synthesizing' | 'reviewing';
// User turns only. One assistant turn can span several streamed
// messages, so counting messages would let a single exchange
// masquerade as a conversation.
const MIN_TURNS_FOR_CREATE = 3;
In phase one you're talking to a purpose-built system agent whose only job is to interview you. The "Create Agent" button doesn't exist until you've taken at least three turns. Not three messages — three user turns, because a single assistant reply can arrive as several streamed messages and counting those would let one exchange impersonate a conversation.
The constant is a product opinion wearing a type annotation. You cannot one-shot an agent definition here, by construction. The reason is not paternalism; it's that a one-line description underdetermines an agent. "Write my release notes" doesn't say which repository, what tone, who reads them, what happens when a release has no user-facing changes. A model handed that will invent answers, and the invented answers are exactly the ones you'll be debugging in three weeks. Forcing a conversation front-loads the questions to the moment when answering them is cheap.
The interviewer runs under its own budgets: 15 turns, 50,000 tokens, 120 seconds of wall clock. An agent whose job is to elicit requirements should not be able to elicit them forever. Budgets on a helper agent aren't a safety measure so much as a design constraint — they force the interview to converge.
Synthesis, and a menu on disk
Hit the button and you're in phase two. The synthesizer streams over SSE, and a
live checklist tracks it through plan_snapshot frames. The seeded plan is
exactly five steps: understand request, discover tools, draft instructions, draft
KPIs, validate definition. You watch them tick.
The second step is more interesting than it sounds. The synthesizer discovers
which tools are available by reading a seeded file on disk — once — rather than
calling a discovery tool. Its prompt says so plainly: "There is no tool_catalog
tool to call."
Put the menu on disk instead of spending a tool call on it. A tool call for a catalog is a round trip, a chunk of context, and a step in the loop, all to retrieve something that was static the whole time. If the model needs a fixed reference to do its job, hand it the reference. Save the tool calls for the things that actually require going somewhere.
From there the synthesizer writes into a fixed skeleton — About, Role, Core Workflows, Communication Style, Constraints, Decision-Making, Output Format — targeting 400–800 words across five to eight sections. A skeleton is not a creative restriction. It's what makes the output diffable, reviewable, and comparable across 159 templates and every agent a user writes by hand.
Phase three is an editable preview. Nothing has been written yet. You can rewrite any of it before it becomes real.
The part that matters: what happens on confirm
Here's the write path.
First, every requested tool key is validated against the live catalog. Not a cached list, not the list the synthesizer thought it had. The reason is in the code comment: this guards against model hallucination, because a synthesizer that emits a plausible-but-wrong tool key "would silently produce an agent with 0 tools at runtime". Invalid keys come back as a 400 with the offending keys named.
That sentence is the best single lesson in the whole path, so let it land. A hallucinated tool key is not a loud failure. It doesn't throw. It produces an agent that looks completely fine in the preview, saves without complaint, appears in your list with a green dot, and then does nothing useful when you run it — because the tool it was told to use resolves to nothing. The failure surfaces days later as "the agent seems dumb."
The general form: anywhere a model's output becomes a key, an ID, or a reference into another system, the failure mode is silence. Validate at write time, against the live source of truth, and fail loudly with the specific bad value. The alternative isn't an error — it's a mystery.
Second, the create contract requires an explicit quota field. There's no default. A caller cannot create an agent without stating the cap, because — from the code — the service "never creates without an explicit cap so the invariant 'every create is cap-checked' holds at the contract layer."
Encode invariants in the contract, not in a caller's good intentions. A default would work fine right up until the day someone adds a fourth creation path, and then the invariant is a comment in a file nobody opened.
What isn't in the row
The agent table holds what you'd expect — name, instructions (a DB CHECK caps
them at 16,384 characters), description, tags, status (CHECK IN 'active',
'paused', 'draft'), KPIs as JSONB, the memory and evolution flags, run counters,
current_version, template_id. The absences are more informative.
There is no model column. Model choice is a service-level concern, not a per-agent field. Bake a model into the agent row and every agent becomes a small monument to whatever was best the week it was created, and upgrading the fleet becomes a migration.
There is no schedule column. Schedules live in a separate service, keyed by agent id. An agent is a definition of behavior; when it runs is somebody else's question. Fusing them into one row is how you end up with a scheduler you can't change without touching the agent model.
Version 1, or nothing
If the row is inserted, two more things must happen: build version 1 — a snapshot
of instructions, KPIs, memories, and tools — and pin it. If either step fails, the
agent row is rolled back and the API returns 503 with a Retry-After. You never
get a half-created agent, and you never get an agent at version zero whose first
edit has nothing to diff against.
The 503 is deliberate. Failing the whole create and telling you to try again is strictly better than handing back a live-looking agent with no version history, because the second one is a debugging problem you inherit silently.
And now the honest seam. Schedules and tool bindings attached at create time are not covered by that guarantee. They're best-effort — attempted, and on failure logged and swallowed. Which means a binding failure can produce a live agent that's missing a tool. We know. The v1 pin is load-bearing for correctness, so it's atomic; the bindings are recoverable by re-attaching, so they're not. It's a real seam, chosen rather than overlooked, and worth stating plainly because every atomicity boundary is a decision about which failures you'd rather have.
Lex, and matching on provenance
The agent you get at signup is named Lex. Lex is not a special row. It's an
ordinary agent provisioned from the research-assistant template, subject to the
same validation, the same v1 pin, the same everything.
The code never identifies it by name. It matches on template provenance, and the comment explains why: "What makes it 'built-in' is provenance, so that's what we match on — never the display name, which the user is free to change." When a migration needed to rename the default agent, it only touched rows still carrying the stock name. If you'd renamed yours, it was left alone.
Match on the immutable fact, not the mutable label. Display names are user data. Any logic keyed on one is a bug waiting for the first person who personalizes something.
The provisioning itself is best-effort by design. Every failure path returns 200
with provisioned: false and a reason. Signup does not fail because agent
creation did — the account is the thing that must exist; the starter agent is a
convenience, and convenience never gets to block the front door.
What a creation path owes you
Validate the model's output against the live world before you persist it. Make the invariant a required field, not a default. Draw the atomicity boundary consciously and say where it is. Keep the mutable labels out of your logic.
None of this shows up in the form. That's the point — the whole job is to make sure that the thing you clicked "create" on is the thing you actually got.
Written by CatalEx Engineering. We build the AI operating layer for AI-native companies — one platform to build, deploy, and run AI agents in production. More at catalex.co.