stackbone.ai
An OpenAI-compatible client pointed at OpenRouter. Same surface as the official
openaiSDK (chat.completions,embeddings,images,models), so any code or tool that speaks OpenAI works against any of the 300+ models OpenRouter resolves.
Two ways to use models
- The
modelyou pass todefineDeepAgent(...)in itsindex.tsdrives a deep agent's main loop. The runtime resolves that model itself; you do not callstackbone.aifor it. stackbone.aicovers the ad-hoc calls: a one-off completion, an embedding, a rerank, an image, or the model catalogue, from inside a tool or a workflow step.
Mental model
stackbone.ai wraps the official openai package with baseURL
overridden to OpenRouter. The SDK builds the underlying OpenAI instance
on the first method call and re-reads the credential from
the environment on every call. When the operator configures or rotates
the model provider while your agent is running, the next call picks up
the new key. Every namespace shares one instance, and the SDK rebuilds
it only when the credential changes.
You reach the surface through the ambient stackbone handle, backed by a
single OpenRouter client per process. stackbone.ai is the entrypoint you
use inside an agent tool and inside a
durable workflow step. Import once:
import { stackbone } from '@stackbone/sdk';. You get the same method
surface, Result envelope, and ai_* error catalogue wherever you
call it.
Required capability: ai.openrouter. Every method awaits the
contract handshake before
it issues the request, in an agent tool and in a workflow step alike.
Configuration
| Source | Falls back to |
|---|---|
createClient({ modelProviderKey }) |
MODEL_PROVIDER_API_KEY |
createClient({ modelProviderBaseUrl }) |
MODEL_PROVIDER_BASE_URL, then https://openrouter.ai/api/v1 |
Any OpenAI-compatible endpoint works: point the base URL at a gateway
(Ollama, LM Studio, LiteLLM, vLLM). On a current runtime image, the key is
optional through the ambient stackbone handle, because the box adds the provider credential, if any, on the
way out. A credential you pass to createClient calls the provider directly and
still needs a key. For a gateway that ignores keys, pass any placeholder, such
as ollama. See Run on Ollama.
In a real workspace you rarely call createClient: the ambient
stackbone handle sends its calls through the box, which uses the provider
configured for it. When you run stackbone dev, the CLI
seeds that provider: a value exported in your shell always wins, otherwise the
CLI uses the provider configured in the Studio Model provider screen. With
nothing configured, stackbone dev holds your agents and workflows at the
boot gate until you
configure a provider or continue without one.
The SDK sends HTTP-Referer and X-Title on every request so
OpenRouter's leaderboard credits the platform.
Chat completions
Call stackbone.ai from inside a tool. The example below is an agent tool
in deep-agents/<name>/index.ts:
import { tool } from '@langchain/core/tools';
import { stackbone, z } from '@stackbone/sdk';
const summarise = tool(
async ({ text }: { text: string }) => {
const result = await stackbone.ai.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: `Summarise:\n\n${text}` },
],
});
if (result.error) throw new Error(result.error.code);
return result.data.choices[0]?.message.content ?? '';
},
{
name: 'summarise',
description: 'Summarise a block of text.',
schema: z.object({ text: z.string() }),
},
);The same call works verbatim from a durable
workflow step: put it inside a function
marked 'use step' so the runtime persists the completion and
retries the step on failure.
The model string is a creator/model-name id. See the
OpenRouter model list for the
convention, and the model catalogue below to
list everything OpenRouter resolves.
Streaming
Pass stream: true and consume the resulting iterator. The Result
envelope only covers connection establishment; once the stream is
open, mid-flight errors propagate through the iterator: wrap your
for await loop in try/catch.
const stream = await stackbone.ai.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Stream a haiku.' }],
stream: true,
});
if (stream.error) throw new Error(stream.error.code);
let text = '';
try {
for await (const chunk of stream.data) {
text += chunk.choices[0]?.delta?.content ?? '';
}
} catch (cause) {
// Mid-flight provider error: same `mapApiError` shape as non-streaming.
}The SDK asks the provider for a final usage-bearing chunk unless you set
stream_options: { include_usage: false } yourself. The last chunk of
the stream carries usage and no content delta. That chunk lets a
streaming call report its tokens like a non-streaming one, so declining
it drops the call off the run's token totals. See
Token usage on the run.
Aborting
chat.completions.create(), embeddings.create() and images.generate()
accept an optional signal: AbortSignal in the second argument. Aborts
surface as ai_aborted in non-streaming calls; in streaming, the iterator
terminates. models.list() takes the signal in its only argument, and
rerank.create() in its params object. Both call the provider with fetch,
so an abort there is ai_network_error.
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await stackbone.ai.chat.completions.create(
{ model: 'openai/gpt-4o-mini', messages },
{ signal: controller.signal },
);Token usage on the run
The runtime attributes a chat completion to the run that was executing your code when you made the call. The call lands on that run's timeline as its own model call, with the model id, the latency and the input and output tokens. Its tokens fold into the run's totals. You report nothing and you thread no run id through your code.
A streaming call reports once your for await loop drains the last
chunk, so its recorded duration includes your own iteration pace.
Only chat completions report usage. embeddings.create(),
images.generate(), models.list() and rerank.create() run inside the
enclosing step, so the run records the step's timing but no tokens for them.
Read the timeline with stackbone runs get <run-id> or in Studio: see
Logging & observability.
Embeddings
const result = await stackbone.ai.embeddings.create({
model: 'openai/text-embedding-3-small',
input: 'Hello, world.',
});
if (result.error) throw new Error(result.error.code);
const [embedding] = result.data.data;For RAG ingest/retrieve you rarely call this directly:
stackbone.rag embeds for you using the same OpenRouter pool.
Rerank documents
rerank.create() scores a list of documents against a query with a rerank
model and returns them best first:
const documents = [
'Invoices are sent on the first day of each month.',
'To reset your password, open Settings and choose Security.',
];
const result = await stackbone.ai.rerank.create({
model: 'voyageai/rerank-3-lite',
query: 'how do I reset my password?',
documents,
});
if (result.error) throw new Error(result.error.code);
for (const { index, score } of result.data) {
console.log(score, documents[index]);
}result.data is { index, score }[], sorted by descending score. index
is the position of the document in the documents array you sent. Each
rerank model scores on its own scale, so compare scores from one model only.
| Param | Required | What it does |
|---|---|---|
model |
yes | The rerank model, as a creator/model-name id. |
query |
yes | The text to score the documents against. |
documents |
yes | The texts to score. An empty array returns [] with no call. |
topN |
no | The provider returns at most this many results (sent as top_n). |
signal |
no | An AbortSignal, passed in the same object. |
The openai package has no rerank method, so rerank.create() calls
POST ${baseURL}/rerank with fetch. It uses the same key and base URL as
the other namespaces. A response without a readable
results[].{ index, relevance_score } list is ai_provider_error: you never
get a partial list.
For RAG retrieval you rarely call this directly:
stackbone.rag.retrieve({ rerank })
reranks its hits for you.
Image generation
OpenRouter does not implement OpenAI's /v1/images/generations
endpoint; it serves image models through /v1/chat/completions with
modalities: ['image'], and the image comes back as a
non-standard message.images[] array of base64 data URLs. The SDK
encapsulates that quirk and surfaces an OpenAI-shaped response:
const result = await stackbone.ai.images.generate({
model: 'google/gemini-2.5-flash-image-preview',
prompt: 'A retro pixel-art skyline at sunset.',
imageConfig: { aspect_ratio: '16:9' }, // forwarded as `image_config`
});
if (result.error) throw new Error(result.error.code);
const [image] = result.data.data;
console.log(image.mimeType, image.b64Json?.slice(0, 32), '…');If the model returns no images (wrong model, content policy, …) the
result is ai_no_image_generated rather than an empty success.
Model catalogue
const result = await stackbone.ai.models.list();
if (result.error) throw new Error(result.error.code);
for (const model of result.data.data) {
console.log(model.id, model.context_length, model.pricing);
}models.list() calls GET ${baseURL}/models directly (instead of
going through the upstream openai.models.list() parser) so
OpenRouter-specific fields like pricing, context_length,
supported_parameters and architecture make it through verbatim.
Errors
The SDK maps upstream OpenAI.APIErrors to stable ai_* codes:
| Code | When |
|---|---|
ai_unauthorized |
401: bad or revoked API key. |
ai_credits_exhausted |
402: the configured model-provider account ran out of credits. |
ai_forbidden |
403. |
ai_validation_error |
400 / 422: malformed request. |
ai_rate_limited |
429. |
ai_moderation_blocked |
451. |
ai_timeout |
408 or APIConnectionTimeoutError. |
ai_aborted |
The caller's AbortSignal fired. |
ai_network_error |
Anything that did not reach the provider (connection reset, DNS, …). |
ai_provider_error |
Anything else from OpenRouter (5xx, unmapped 4xx). |
ai_no_image_generated |
images.generate() succeeded but the model returned no images. |
openrouter_key_missing |
The SDK called the provider directly with no key: a createClient credential, or a runtime that does not route model calls through the box, with MODEL_PROVIDER_API_KEY and modelProviderKey both absent. The code name is kept for wire compatibility. |
error.meta includes status, model, and OpenRouter's own code and
type (as openrouterCode / openrouterType) when present. error.cause
is the original OpenAI.APIError instance for callers that want to
introspect it.
rerank.create() goes through fetch, not the openai client. A non-2xx
status maps through the same table, with the provider's own message when it
sends one, and error.meta carries status and model. Two cases differ:
- A
fetchthat throws isai_network_error, withmodelinerror.meta. That includes an abortedsignal, which is notai_abortedhere. - A 2xx whose body has no readable results is
ai_provider_error.
The contract gate adds contract_version_unsupported,
capability_unavailable, contract_unreachable and
contract_malformed. See
overview.
Where to go next
- Agents: set your
agent's reasoning model and reach
stackbone.aifrom its tools. - Workflows: call
stackbone.aifrom a durable'use step'so completions are persisted and retried. See also Building durable AI agents. stackbone.models: resolve themodelyou pass here from a key an operator can repoint in Studio.stackbone.rag: usesstackbone.ai.embeddingsinternally for ingest and retrieval.stackbone.approval: pair withstackbone.aito build LLM tools that pause for a human before executing.- OpenRouter docs: the upstream provider.