Skip to content

Run and RunHandle

ts
const app = Thena.create(MyWorkflow, config); // not async
const exec = app.run({ prompt: "hi" });
const result = await exec;
await app.dispose();

Thena.create(WorkflowClass, config?)

Returns a WorkflowApp. Not async — building the app waits for nothing.

ts
Thena.create<T = string, D extends RunData = RunData>(
  WorkflowClass, config?: ThenaConfig
): WorkflowApp<T, D>

T is the run's output type; D types run({ data }) and context<D>().data.

bootstrapWorkflow is the deprecated async form

It still works so 0.6 code does not break. Use Thena.create.

WorkflowApp

MemberWhat it does
run(options)starts a run, returns a RunHandle synchronously
use(plugin)attaches a plugin. await it, and call it before run
dispose()aborts in-flight runs, waits for them, shuts plugins down

Concurrent run calls are safe: each opens its own RunContext.

WorkflowRunOptions

ts
await app.run({
  prompt: "Review src/",
  state: { memory: ["userId: 123"] },
  data: { accountId: "acme" },
  budget: { maxChatCalls: 20 },
  signal: AbortSignal.timeout(30_000),
  observe: true,
  report: false,
  log: "verbose",
});
OptionTypeNotes
promptstringthe first user message of the run. Not the agent's prompt, which is its system markdown
statePartial<State>where the run starts: history, tasks, memory. handle.state gives you the one to pass next
dataDyour channel — never goes to the model or the report
budgetRunBudgetwithout it, nothing is measured
signalAbortSignalcombines with the handle's abort(); first to fire wins
observebooleanforces observation on
reportboolean | ReportOptionsoverrides ThenaConfig for this run
logLogConfigoverrides ThenaConfig for this run

RunHandle

PromiseLike, so await gives the result. Without await, you have the run.

MemberTypeNotes
runIdstringsynchronous, before the first turn
resultPromise<T>a plain Promise — composes with Promise.all
signalAbortSignalyours combined with this handle's abort()
abort(reason?)voidthe reason arrives at the caller's catch
onEvent(cb)() => voidreturns the unsubscribe
eventStreamAsyncIterable<ExecutionEvent>gives backpressure
onToken(cb)() => voidreturns the unsubscribe
textStreamAsyncIterable<string>
then / catch / finallyreturn plain Promises — chaining discards the handle

Late subscribers receive what already happened before the new items, buffered up to 500 events.

Nothing is emitted unless the run is observed

Observation turns on with report, log, a plugin with onEvent, or an explicit observe: true. Otherwise the run takes the zero-cost path — no tree, no events, no streaming — and you get a one-time console warning.

See Streaming.

RunBudget

ts
budget: {
  maxDurationMs: 60_000,
  maxChatCalls: 20,
  maxToolCalls: 50,
  maxTokens: 100_000,
  maxCostUsd: 0.5,
  mode: "stop",
  onExceeded: (info) => console.warn(info.reason),
}
KeyNotes
maxDurationMswall clock for the whole run
maxChatCallsmodel calls
maxToolCallstool executions
maxTokensprompt + completion, only what the provider reports
maxCostUsdrequires costPer1kTokens on the provider
mode"stop" (default) ends gracefully; "throw" raises BudgetExceededError
onExceededcalled once, when the budget blows
ts
interface BudgetUsage {
  chatCalls: number;
  toolCalls: number;
  tokens: number;
  costUsd: number;
  elapsedMs: number;
}

Read it mid-run with ctx.usage() or ctx.budget.

The ceiling is a barrier, not a mid-sentence cut

Stopping is checked between units of work, and a model call is only counted once it answers. A run can spend one call past the limit — and one more per level of nesting when a tool starts a sub-workflow. It is a known, constant upper bound.

A nested run counts against its parent's budget. Pass an explicit budget to runtime.run() to give it a ceiling of its own; whichever blows first wins.