Drop stella in as your agent engine
Replace your hand-rolled agent loop with stella's, keeping your own models, tools, keys, and data — and end on an integration test that needs no API key.
You shipped an AI feature. The first version was a while loop around a
completions call, and it worked. Then it went into production, and the loop
grew: a step cap, because a model called the same tool nine times. A way to
shorten the history, because it outgrew the context window. A retry, then a
smarter retry, because a 429 and a 400 shouldn't be treated the same way.
At some point it stopped being a feature and started being a system nobody
really owns.
None of that is your product. All of it sits on your critical path.
This guide replaces that loop with stella's, without giving up anything you care about — your models, your keys, your tools, your data, your bill.
The move, in one paragraph
You run stella-serve as a process next to your app. When a turn starts,
the engine drives the loop and asks your app to do the two things that
touch anything sensitive: call the model, and run the tool. The engine has
no HTTP client, no TLS stack, and no provider code of its own, so it simply
can't make either call itself.
That's the whole design, and it's why this is safe to put inside a product: the engine is just a scheduler for work your app does. It never holds a credential, so it can never leak one.
Before you start
A Rust toolchain, onceOnly to build the server. Your app can be TypeScript, Python, Go, or anything else that speaks HTTP — nothing you write has to be Rust unless you pick the linked option below.
An AI feature that already worksPort something that's running today, not something you're still designing. You want exactly one thing changing this afternoon.
A decision on shape, made firstSidecar process, or code linked directly into your binary. This changes your licensing, so decide before you build, not after.
Pick the shape first. Linking stella-core directly into a closed-source
binary needs a commercial license. Whether the sidecar needs one depends on
facts about your deployment. Neither option is automatically free — the
licensing section
spells out exactly which facts decide it, and
licensing@oxagen.sh will tell you if the
answer is "you need nothing."
Step 1: Get a server running
Two commands. There's no stella serve subcommand, and no release download
includes the server — it's a separate program with its own binary.
cargo build --release -p stella-serve --bin stella-serve
STELLA_SERVE_BIND=127.0.0.1:8137 \
STELLA_SERVE_TOKEN="$(openssl rand -base64 32)" \
./target/release/stella-serve
# stella-serve listening on 127.0.0.1:8137It's a fast build — the server only pulls in two libraries, not the full CLI or the terminal UI.
Write down the version it prints, next to your client code. The message format only ever adds fields, but pinning a version and running a round-trip test is how you find out about a change on your own schedule instead of in production.
Step 2: Port the loop, not the feature
Here's the part people over-plan. Your existing feature already has the two functions the engine needs. Find them:
Whatever code wraps your model gateway today. It becomes the handler for
provider_request messages. Your routing, your keys, your logging, your
rate limits — all of it stays exactly where it is.
Your switch statement over tool names, behind whatever checks you
already run. It becomes the handler for tool_request messages. The
engine never sees your database, your sandbox, or your permission
system.
Everything between those two functions — the step counting, threading the history, shortening it, retrying — is what you get to delete.
The complete working host is about eighty lines of plain Node with no dependencies. The reference page walks through the six routes, the four message types, and the error types. Don't re-read all of that here — open it in another tab and come back once your first turn completes.
Three assumptions a correct host must avoid, because each one causes a bug
that only shows up under load. event messages are not guaranteed to
arrive in the same order as request messages. More than one tool_request
can be outstanding at once, because the engine runs read-only tools at the
same time — match responses by request_id and never process them one at a
time. And the login check runs before routing, so a wrong path returns
401, not 404 — a 401 doesn't necessarily mean your token is bad.
Step 3: The payoff — a test that costs nothing
This is the real reason to do this port, and it's worth doing on day one instead of day thirty.
Because your app owns the model call, you can swap in a scripted reply instead of calling your real gateway. The engine can't tell the difference: it sends the same messages, runs the same steps, threads the same history, and reaches the same outcome.
So a full, multi-step agent turn becomes an ordinary unit test. No network, no key, no spend, and it runs in milliseconds.
import { test } from "node:test";
import assert from "node:assert/strict";
test("the model can call a tool and answer from its result", async () => {
const replies = [
{ tool_calls: [{ call_id: "c1", name: "get_weather", input: { city: "Paris" } }],
finish_reason: "tool_calls" },
{ text: "It is 18C and clear in Paris.", finish_reason: "stop" },
];
let n = 0;
const outcome = await runTurn({
prompt: "What is the weather in Paris?",
// your gateway, replaced by an array
onProviderRequest: () => withUsage(replies[n++]),
onToolRequest: (name, input) => dispatch(name, input),
});
assert.equal(outcome.status, "completed");
assert.match(outcome.text, /18C/);
assert.equal(n, 2, "one call to ask for the tool, one to answer from it");
});That test covers more than it looks like. The engine recorded the
assistant's tool call, matched your result to it by call_id, rebuilt the
conversation in your provider's format, and ran the second step from there.
That's the machinery you deleted in step 2 — now under test, for free.
Write tests like this for the cases you can't afford to hit in production: a tool that errors, a model that returns bad arguments, a rate limit in the middle of a turn, a turn that hits its step cap. Each one is just a scripted array, and none of them cost a cent.
Report failures as the error result of a tool call, instead of throwing an
exception. The model reads that text and can often correct itself, which is
usually what you want. And say why a model call failed: transport and
rate_limited get retried with backoff, while auth, unknown_model,
malformed, cancelled, and terminal fail the turn right away. Reporting
a rate limit as terminal silently turns off the backoff you were trying to
get.
Step 4: Before it carries traffic
A short list. Every item here is a real failure someone has actually hit.
One process per trust boundarySeveral provider and sandbox settings apply to the whole process, so running multiple tenants in one process isn't safe. Run one engine per tenant, inside whatever isolation you already use for untrusted code.
Keep the port privateUse loopback, or a private network behind the token. There's no Host
check and no CORS handling — never expose this port directly to a
browser.
Cancel turns you abandonEach running turn holds an OS thread, and at most 32 can run at once.
Past that limit you get a 429. POST /v1/turns/{id}/cancel shuts a turn
down cleanly and still reports a final cost.
Record every step_usage eventPer-step tokens, cost, model, and duration. cost_usd is just the sum of
what your app reported, so your own billing system stays the source of
truth — these events are how you double-check it.
Some things genuinely don't exist yet: no resuming a stream, no
server-side conversation state, no approval routes, no /metrics endpoint.
Plan around these instead of discovering them the hard way:
not built yet.
Where this goes next
You now have an agent loop that runs in CI without needing a key. The natural next step is to make CI actually care about it, because the engine is a real dependency now, and dependencies can break.
Run stella and Claude Code side by side on the same set of tasks on every pull request. Block the merge on loop correctness, which is exact and cheap to check. Report the quality difference separately, since that part is neither. The guide that pairs with this one.
The reference this guide links into — all six routes, the four message types, the full environment settings, the licensing details, and the eighty-line host in full.
Related reading
The AgentEvent types you get back in event messages, and the rule
your parser needs to follow to stay compatible going forward.
What a wrapper plugin adds around a turn, if you want to know what you bought.
The command-line ways into the same engine, for the parts of your workflow that aren't a product surface.
Understand what a run cost, and why
Follow one expensive run from the dollar figure down to the exact bytes the model was sent — stats, the Observatory, stella inspect. Entirely local.
Gate on engine quality in CI
Run stella and Claude Code side by side on one task set on every pull request. Block the merge on loop correctness. Report the quality difference, but never block on it.