Setting up setupAgentCore
Everything the runtime needs, and everything you can change about it.
const runtime = await setupAgentCore({
storage, // required
queue, // required
bus, // required
kv, // required
streams, // needed when web servers and workers run apart
resolveModel, // required
admin, // optional
config, // optional
});The call is async because the operational store is opened here. A store that cannot be opened is a startup error rather than a surprise on the first run — quietly falling back to memory would lose every record, and a dashboard showing nothing looks exactly like no traffic.
storage — your data
Threads, messages, the thread record and usage. See Ports and adapters for the full interface and its invariants.
storage: new PrismaStorage(prisma)Reference implementations: PrismaStorage, SqliteStorage, MemoryStorage. Write your own for anything else — the engine imports no driver.
Every method receives a trailing context carrying the run's state, which is how a query scopes itself to a tenant.
queue — dispatch
queue: new QStashQueue(new Client({ token }), { url: `${appUrl}/api/queue/agent-run` })At-least-once. The engine is idempotent under redelivery through the per-thread run lock.
| Adapter | Use |
|---|---|
QStashQueue | production, serverless |
InlineQueue | development — dispatches on a later tick, honours delays, does not survive a restart |
MemoryQueue | tests |
InlineQueue needs wiring after the core exists, because the two need each other:
const queue = new InlineQueue();
const runtime = await setupAgentCore({ queue, /* … */ });
queue.bind((job) => runtime.worker.handleJob(job));Nothing dispatches until bind runs.
An adapter must never throw for
delaySeconds. Delivering immediately is an acceptable degradation; rejecting the option breaks approval expiry, which is scheduled from inside a parked tool call.
bus — live fan-out
bus: new RedisBus(redis)At-most-once, deliberately. A dropped frame is recovered by reading the thread record and the run stream again from the client's cursor — which is what lets the bus be Redis pub/sub, Upstash, Ably, or Postgres LISTEN/NOTIFY.
RedisBus takes an optional heartbeat interval, which drives the watchdog:
new RedisBus(redis, 60_000) // defaultkv — hot state
kv: new RedisKv(redis)Thread state cache, run identity, HITL handoff keys, and the segment and attempt counters. Everything here is reconstructible except while a run is in flight.
The thread record's seq is not here any more: the store mints it when it stores an entry.
streams — run streams
streams: new RedisRunStreams(redis)Each run segment writes its live events (text, reasoning, tool calls, step ends) to its own short-lived stream. A client reads it through runtime.events.follow or sse. See Run streams.
| Adapter | Use |
|---|---|
RedisRunStreams, UpstashRunStreams | production; keep streamGraceMs short, since Redis holds it in memory |
PrismaRunStreams | production on your SQL database; needs the RunStream and RunStreamEvent models |
SqliteRunStreams | one machine, or development |
MemoryRunStreams | tests |
Leave it out and the runtime keeps streams in memory, and logs a warning once. That is fine for one process. It is not fine when web servers and workers are separate processes: a web server could never read what a worker wrote, so a tab would see nothing until each step was saved. Pass a real one there.
resolveModel — your models, your shape
The one required function. Models can come from a config file, a database, or provider SDKs; the platform only ever sees the resolved pair.
resolveModel: (name) => {
const model = registry[name];
if (!model) throw new Error(`Unknown model: ${name}`);
return { instance: () => model, contextWindow: 128_000 };
}| Field | Meaning |
|---|---|
instance() | Returns the AI SDK model. Called per step, so it can be lazy. |
contextWindow | Feeds compaction. Wins over the nativeWindows config table. |
Throw for an unknown key. A model name can reach this function from a run request or from a subagent the model itself named. Falling back silently means a hallucinated model name quietly becomes a different one.
Per-tenant models are just a closure over your registry:
resolveModel: (name) => {
const [tenant, model] = name.includes(':') ? name.split(':') : [null, name];
return {
instance: () => (tenant ? tenantModels[tenant][model] : registry[model]),
contextWindow: windows[model] ?? 128_000,
};
}admin — operational history
Omit it and it is chosen from the environment:
| Environment | Store |
|---|---|
AGENTIC_KIT_ADMIN_DATABASE_URL set | Postgres, tables prefixed agentic_ |
| otherwise | SQLite at AGENTIC_KIT_ADMIN_DB, or agentic-kit-admin.sqlite |
Pass one to decide for yourself:
admin: SqliteAdminStore.open(db) // same file as your data, if you like
admin: new PostgresAdminStore(pool) // explicit
admin: new MemoryAdminStore() // tests — nothing touches the diskUse MemoryAdminStore in tests. The default writes a file, so a suite that forgets this shares one database across every test.
This is not a port. You do not implement AdminStore; you read it back through runtime.admin.*. See Observability.
What you get back
Beyond the agent factories, the runtime exposes the reads and the event stream:
runtime.events.sse(threadId, { cursor, lastMessageId, signal }) // SSE stream + headers, any framework
runtime.events.follow(threadId, { cursor, signal }) // the same frames, as an async iterable
runtime.streams.read(streamId, after, signal) // one run stream on its own
runtime.getThreadSnapshot(threadId, state) // hydrate a client
runtime.admin.overview() // operational reads
runtime.worker.handleJob(job) // the queue consumerThe follow carries the replay-then-tail rules with it, and reads the thread record and the run streams as one, so a route handler is a cursor and a response — see HTTP API.
config — behaviour
Every field is optional and merges over the defaults.
config: {
hitlTtlMs: 5 * 60_000,
maxSteps: 12,
recordPayloads: false,
billingPreCheck: async ({ threadId }) => ({ ok: await hasCredit(threadId) }),
}The full table is in Configuration. The settings most worth deciding deliberately:
| Setting | Why |
|---|---|
maxSteps | Your ceiling on a runaway loop. The default of 25 is generous. |
tokenBudget | Unbounded by default. |
runLockLeaseSeconds | Must exceed your longest run segment. |
hitlTtlMs | How long a human has. 15 minutes suits a chat; a back-office approval may want a day. |
recordPayloads | On by default. Turn it off when prompts carry sensitive data. |
billingPreCheck | The only hook that can refuse a run before it costs anything. |
providerOptions | Provider-specific settings for every run — see Provider options. |
Assembling it
Development
import { Database } from 'bun:sqlite';
import { setupAgentCore } from 'agentenkit';
import { SqliteRunStreams, SqliteStorage } from 'agentenkit/adapters/sqlite';
import { InlineQueue } from 'agentenkit/adapters/inline';
import { MemoryBus, MemoryKv } from 'agentenkit/adapters/memory';
import { SqliteAdminStore } from 'agentenkit/admin/sqlite';
const db = new Database('agentic-kit.sqlite');
const queue = new InlineQueue();
export const runtime = await setupAgentCore({
storage: new SqliteStorage(db),
streams: new SqliteRunStreams(db),
admin: SqliteAdminStore.open(db),
bus: new MemoryBus(),
kv: new MemoryKv(),
queue,
resolveModel: (name) => ({ instance: () => openai(name), contextWindow: 128_000 }),
});
queue.bind((job) => runtime.worker.handleJob(job));Production
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export const runtime = await setupAgentCore({
storage: new PrismaStorage(new PrismaClient()),
bus: new RedisBus(redis),
kv: new RedisKv(redis),
streams: new RedisRunStreams(redis),
queue: new QStashQueue(
new Client({ token: process.env.QSTASH_TOKEN! }),
{ url: `${process.env.APP_URL}/api/queue/agent-run` },
),
// admin omitted — AGENTIC_KIT_ADMIN_DATABASE_URL decides
resolveModel,
config: { maxSteps: 12, runLockLeaseSeconds: 900 },
});Tests
export async function testRuntime(model: LanguageModelV1) {
const queue = new MemoryQueue();
const runtime = await setupAgentCore({
storage: new MemoryStorage(),
bus: new MemoryBus(),
kv: new MemoryKv(),
streams: new MemoryRunStreams(),
queue,
admin: new MemoryAdminStore(), // never touch the disk
resolveModel: () => ({ instance: () => model, contextWindow: 128_000 }),
config: { hitlTtlMs: 1_000 }, // make expiry observable
});
return { runtime, queue };
}Drive the worker by hand — await runtime.worker.handleJob(queue.items.at(-1)!) — and you can step a run one dispatch at a time.
Registering agents in the same module
The agent registry lives inside the runtime's closure, and a queue job resolves its handle by name from that registry.
In a framework that gives each route its own module instance — Next.js does — a worker route importing the runtime from a module that does not also register the agents will resolve every job to an unknown agent.
// lib/runtime.ts — one module, imported everywhere
export const runtime = await setupAgentCore({ /* … */ });
export const chat = runtime.createStreamTextAgent({ name: 'chat', model: 'gpt-4o' });
export const support = runtime.createStreamTextAgent({ name: 'support', model: 'gpt-4o' });Reusing connections in development
A framework that re-evaluates this module on hot reload opens a fresh database pool and Redis connection every time, and nothing closes them. After enough edits Postgres refuses new clients and the app dies with it.
const dev = process.env.NODE_ENV !== 'production';
const cache = globalThis as unknown as { agentRedis?: RedisClient; agentPrisma?: PrismaClient };
const redis = cache.agentRedis ?? createClient({ url: process.env.REDIS_URL });
if (!redis.isOpen) await redis.connect();
const prisma = cache.agentPrisma ?? new PrismaClient();
if (dev) { cache.agentRedis = redis; cache.agentPrisma = prisma; }Production evaluates the module once, so the cache is never populated there.
Several runtimes in one process
Nothing stops it — each has its own registry and its own ports. Two reasons it comes up:
- A separate worker service. It needs the same runtime module, because a job resolves its handle by name.
- Isolated stacks per region or tier. Give each its own storage and queue.
Do not build one runtime per tenant. Tenancy belongs in run state, not in a runtime per customer — a runtime per tenant multiplies connection pools by your customer count.