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.

What the engine owns, and what never leaves your appyour appprovider keys · gateway · routingtools · sandbox · RBAChistory · billing · your datastella-servestep loop · tool dispatchcompaction · token budgetretry class · loop detect · capsasksanswersno HTTP client, no TLS stack, no provider adapter — it cannot leak a key it never holds

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, once

Only 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 works

Port something that's running today, not something you're still designing. You want exactly one thing changing this afternoon.

A decision on shape, made first

Sidecar 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:8137

It'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:

The one that calls the model

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.

The one that runs a tool

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.

The same host, with and without a modelone hostthe code you just wroteyour gatewayreal models · real spendscripted repliesa function returning JSONthe identical loopsame frames · same stepsone of those two paths costs nothing and finishes in milliseconds — that is the one CI runs

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 boundary

Several 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 private

Use 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 abandon

Each 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 event

Per-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.