workflow-api
@alexkroman1/aai/workflow-api — the client side of a deployed agent’s HTTP
API, from one import path.
Start with createAgentClient — one object for everything one agent answers. createWorkflowApiClient is the narrower one, for a caller that genuinely only has workflows (a page already knows what it is): the agent client CALLS it, so the two are a superset and its narrower factory rather than two implementations, and the barrel exists because pointing the subpath at either one directly would be an import cycle.
It also owns the RUN vocabulary — the snapshot union and its guard — which
used to sit on the root barrel beside agent() and tool(). See the
re-export below for the line that puts it here.
Two of the three …Of<typeof def> helpers are on the ROOT as well.
WorkflowInputOf and WorkflowRunOf came here with the rest of
that vocabulary on the reading that a body names the input and a page names
the output, so neither is agent.ts. Both are in fact author-side: a
workflows/*.ts body must annotate its parameter with WorkflowInputOf
(nothing checks a hand-written one — see that type’s doc), and a *_status
tool holds a WorkflowRunOf. Those are files workflow() and tool() live
in, which is the root barrel’s membership test, so an author reaching for the
type the compiler is asking for no longer has to find the subpath for callers
OUTSIDE the agent. This capability still OWNS them, by the rule that a name
on both . and a narrower subpath belongs to the narrower one; the reference
renders them on the root page and links here.
WorkflowOutputOf stayed HERE, and the template API ratchet is what
settled that: no author-side example exercises it, because a page is what
parameterizes useWorkflowRun<…> and a status tool takes WorkflowRunOf,
which composes it in. Moving all three because they read alike would have
published a name with no author to reach for it.
What this subpath is NOT is the SERVER’s half. Four names were here that
only the thing ANSWERING these routes ever needed — the wait clamp
(clampWorkflowWait) and its ceiling (MAX_WORKFLOW_WAIT_MS), the
terminal-status list (TERMINAL_WORKFLOW_STATUSES), and the route prefix
(WORKFLOW_API_PREFIX) — and they are on @alexkroman1/aai/internal now.
clampWorkflowWait is the clearest: its own doc says both ends share it, and
the browser client does share it, through a RELATIVE import inside
workflow-api-client.ts. The public export existed so aai-runtime could
reach the same copy, which is a fact about our packaging rather than an
affordance a caller used — a caller passes wait a number and the client
clamps it.
Six more were tried and PUT BACK, and the docs build is what said no. The
four ctx.workflows option bags (StartOptions, FindOptions,
StreamOptions, WakeUpOptions) plus AnyWorkflowDef and WorkflowBody
also have aai-runtime as their only in-repo importer, which is the evidence
that reads like a case for moving them — and it is the wrong evidence. They
are the PARAMETER and MEMBER types of WorkflowClient and WorkflowDef,
both of which are on the ROOT barrel because ToolContext.workflows and
workflow() name them; in-repo tool code passes object literals, so nobody
imports the bag while every author reads it. Moved, TypeDoc reports six
“referenced by … but not included in the documentation” warnings and
treatWarningsAsErrors fails the build — the same rule WorkflowDef and
WorkflowRunBase are already re-exported here under. Suppressing it via
intentionallyNotExported would leave options?: StartOptions on the
ctx.workflows reference page with nowhere to click, which is a worse
outcome than a wide subpath.
Functions
Section titled “Functions”createAgentClient()
Section titled “createAgentClient()”createAgentClient(
options):AgentClient
Create a client for one agent.
Same options as createWorkflowApiClient — which agent, on whose authority, and for how long — and the same advice: hoist it out of anything that re-runs.
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”createWorkflowApiClient()
Section titled “createWorkflowApiClient()”createWorkflowApiClient(
options):WorkflowApi
Create a workflow API client.
Hoist it out of anything that re-runs. In React it belongs at module scope —
useWorkflowRun in @alexkroman1/aai-ui holds the client in a ref
precisely so a fresh object
per render does not restart its watch, but a client built in render is still a
new fetch closure every time and reads as though it were free.
Parameters
Section titled “Parameters”options
Section titled “options”Returns
Section titled “Returns”isTerminal()
Section titled “isTerminal()”isTerminal<
R>(run):run is TerminalWorkflowRun<R>
Is this run finished?
A type guard rather than a boolean, so the narrow it performs is usable:
if (isTerminal(run)) leaves run.status as the three-member union a caller
can switch over exhaustively. Accepts undefined (nothing started yet, or the
first poll has not landed) because that is what every call site holds.
Type Parameters
Section titled “Type Parameters”R
Parameters
Section titled “Parameters”WorkflowRunSnapshot<R> | undefined
Returns
Section titled “Returns”run is TerminalWorkflowRun<R>
readEventStream()
Section titled “readEventStream()”readEventStream(
body,signal?):AsyncGenerator<EventStreamFrame>
Parse an SSE byte stream into frames, with eventsource-parser.
The parser is a dependency rather than a hand-rolled line splitter, and the three edges that decided it are the three a splitter gets wrong:
- Splitting on
"\n\n"only. The spec permits\n,\r\nand\r, and a CRLF stream is\r\n\r\n— no two adjacent\n, so not one frame ever parsed, and an intermediary re-terminating lines is not our choice to make. line.startsWith("event: ")requires the space the spec makes optional.- Keeping only the LAST
data:line rather than joining a multi-line one.
Three properties of the parser this leans on. feed invokes onEvent
SYNCHRONOUSLY for every complete event in the chunk, so a batch is collected
per read and yielded in arrival order. An event with no data: line at all is
not dispatched (also per spec); every frame these routes emit carries one. And
a chunk ending in a lone \r holds that byte back, because it may yet turn
out to be the first half of a \r\n — so a CR-ONLY stream chunked per frame
dispatches one frame behind, and its last frame not at all. Nothing emits
CR-only endings, and the outcome if anything did is the safe one for every
reader here: a stream that ends with no final frame is read as a dropped
connection.
signal is optional because most callers already own the fetch that opened
the body — aborting that ends the read. Pass one when the reader’s lifetime is
shorter than the request’s.
Parameters
Section titled “Parameters”ReadableStream<Uint8Array<ArrayBufferLike>>
signal?
Section titled “signal?”AbortSignal
Returns
Section titled “Returns”AsyncGenerator<EventStreamFrame>
Type Aliases
Section titled “Type Aliases”AgentClient
Section titled “AgentClient”AgentClient =
WorkflowApi&object
Sealed
Everything one agent answers: every WorkflowApi call, plus the front door.
An intersection rather than a redeclaration — the workflow half must not be describable twice.
Type Declaration
Section titled “Type Declaration”baseUrl
Section titled “baseUrl”
readonlybaseUrl:string
The agent’s base URL, normalized — no trailing slash.
Here because a caller that has this client should not also be threading the
string it was built from: a webhook to register, a link to print, a curl
to paste in a bug report all want it, and re-deriving it invites the
trailing-slash //workflows 404 this normalizes away.
config()
Section titled “config()”config():
Promise<{greeting?:z.ZodOptional<z.ZodString>;name?:z.ZodOptional<z.ZodString>;page:z.ZodEnum<{static:"static";voice:"voice"; }>;sessionUrl?:z.ZodOptional<z.ZodString>; }>
What the agent says it IS: { name?, greeting?, page?, sessionUrl? }.
The one read that works on EVERY agent, whatever shape it is, and the one a
caller starts with — page (absent reads as "voice") is how you know
whether there is a session to open at all, and sessionUrl is the current
one. Re-read it on every connect rather than storing it: on the platform
it names the agent’s sandbox, and that URL changes when the sandbox is
replaced by an idle reclaim or a redeploy.
Unauthenticated on a deployed agent, exactly like the page it describes — so
this call works with no token, and a workflow API closed by
AAI_WORKFLOW_API_TOKEN does not close it.
Returns
Section titled “Returns”Promise<{ greeting?: z.ZodOptional<z.ZodString>; name?: z.ZodOptional<z.ZodString>; page: z.ZodEnum<{ static: "static"; voice: "voice"; }>; sessionUrl?: z.ZodOptional<z.ZodString>; }>
AnyWorkflowDef
Section titled “AnyWorkflowDef”AnyWorkflowDef<
R> =object
Any workflow definition, for a signature that only needs its OUTPUT type.
Not WorkflowDef<ToolInputSchema, R>, which is the obvious spelling and does
not work: a body’s input is a function PARAMETER, so it is contravariant, and
a run taking { topic: string } is not assignable to one taking the open
Record<string, unknown>. Every schema-carrying workflow would fail to match.
Typing the parameter as never inverts that — never is assignable to every
parameter type — which is exactly right for a position that only ever reads
R, and makes the def unusable for CALLING the body, which nothing here does.
Type Parameters
Section titled “Type Parameters”R = unknown
Properties
Section titled “Properties”description?
Section titled “description?”
optionaldescription?:string
input?
Section titled “input?”
optionalinput?:ToolInputSchema
output?
Section titled “output?”
optionaloutput?:StandardSchemaV1<unknown,R>
run:
WorkflowBody<never,R>
uploads?
Section titled “uploads?”
optionaluploads?: readonlystring[]
ClientConfigResponse
Section titled “ClientConfigResponse”ClientConfigResponse =
z.infer<typeofClientConfigResponseSchema>
Parsed body of GET /client-config.
EventStreamFrame
Section titled “EventStreamFrame”EventStreamFrame =
object
One parsed frame. Comment frames (the heartbeats an idle stream sends) are
skipped rather than yielded, and so is a frame with no event: name — the
routes here name every frame they send, and an unnamed one cannot be
classified by any caller.
Properties
Section titled “Properties”data:
unknown
The frame’s data: line, JSON-parsed, or undefined when it was not JSON.
Never a reason to tear the stream down: a run frame carries a WHOLE snapshot, so the next one restates the same state, and a progress read is re-opened from where it left off.
event:
string
The frame’s event: name — run, chunk, done, idle, missing.
FindOptions
Section titled “FindOptions”FindOptions =
object
Options for WorkflowClient.find.
Properties
Section titled “Properties”limit?
Section titled “limit?”
optionallimit?:number
Most runs to return, newest first. Defaults to
DEFAULT_WORKFLOW_FIND_LIMIT and is clamped to
MAX_WORKFLOW_FIND_LIMIT.
StartOptions
Section titled “StartOptions”StartOptions =
object
Per-run options for WorkflowClient.start — ctx.workflows.start, from a
TOOL. A caller OUTSIDE the agent (a page, a script) starts a run through
WorkflowApi.start, whose options are WorkflowStartOptions: the same
key, and a signal where this carries notify, which needs a session to
speak into.
Properties
Section titled “Properties”
optionalkey?:string
A caller’s own handle on this run, for looking it up again later with
WorkflowClient.find.
This is the one piece of durable-workflow machinery the Workflow DevKit
has no equivalent for, and it is kept because a VOICE agent is broken
without it. start resolves with a runId; the natural place a tool puts
it is a sessionSlot, and a session’s slot values are swept
SESSION_RESUME_GRACE_MS
after the caller hangs up. So the run outlives the session and the only
handle to it does not. Passing key: ctx.sessionId (or a phone number, an
account id, an upload id) means the next turn — or the next CALL — can find
the run again without the agent maintaining an index of its own in a database
it brought.
Not unique: starting twice with one key is legal and find returns the
newest first. Deduplicating is a decision only the caller can make.
notify?
Section titled “notify?”
optionalnotify?:boolean|string
Have the agent SAY SOMETHING when this run finishes, without being asked.
true takes the default instruction (“tell the caller the result, briefly,
in your own words”); a string replaces it. Either way the agent takes an
ordinary interruptible turn built from the run’s own output — the model
writes the sentence, because it is the only thing that knows what the
caller has already heard.
This is what makes “I’ll let you know” true. A voice tool that starts durable work answers the turn immediately and the work lands minutes later with no turn to land in, so before this the caller had to think to ask again — and an agent that had promised an update never gave one.
Two limits, both by construction. It reaches the session that STARTED the
run and only while that session is alive: a run outlives the call, and an
announcement into a call that has ended is nobody’s. And it needs a
transport that can take an unprompted turn — pipeline mode can, S2S has no
such verb, so on an S2S agent this is a logged no-op rather than an error.
Both are why key stays the durable handle: the next call finds the run.
StreamOptions
Section titled “StreamOptions”StreamOptions =
object
Options for WorkflowClient.stream.
Properties
Section titled “Properties”namespace?
Section titled “namespace?”
optionalnamespace?:string
Which of the run’s streams to read. A run may keep several — getWritable
takes the same option — so a workflow can separate, say, progress from log
output. Omitted, this is the run’s default stream.
startIndex?
Section titled “startIndex?”
optionalstartIndex?:number
Chunk index to start from, 0-based and INCLUSIVE — the chunk at this index
is the first one you receive. Negative counts back from the end (-3 reads
the last three), which is what a reconnecting reader wants when it does not
know how far it got.
Defaults to 0 — the whole stream from the beginning, since chunks are
retained with the run rather than being live-only. 0 and an omitted value
are the same request, which is what makes a cursor safe to send
unconditionally: a reader that has consumed n chunks passes n and
receives exactly what it has not seen, with no special case for n === 0.
Inclusive is a decision, not a description, and the alternative shipped
briefly. An EXCLUSIVE floor (“what came after the index I last saw”) reads
naturally for a poll loop and cannot be spelled here: the cursor before
chunk 0 is -1, and -1 already means “the last chunk alone”. So it forces
every caller to special-case its own origin into an omitted parameter, and
the off-by-one at that boundary is what a default followOutput was losing
— the first progress line of every run.
TerminalWorkflowRun
Section titled “TerminalWorkflowRun”TerminalWorkflowRun<
R> =Extract<WorkflowRunSnapshot<R>, {status:"completed"|"failed"|"cancelled"; }>
A run in a status nothing will change again.
Type Parameters
Section titled “Type Parameters”R = unknown
UploadBody
Section titled “UploadBody”UploadBody =
Blob|ArrayBuffer|ArrayBufferView|string
What an upload call accepts as the file’s bytes.
UploadOptions
Section titled “UploadOptions”UploadOptions =
object
Options for an upload.
Properties
Section titled “Properties”
optionalname?:string
Filename to store. Defaults to a File’s own name, else "".
onProgress?
Section titled “onProgress?”
optionalonProgress?: (progress) =>void
Called as the bytes leave, so a page can draw a progress bar over the one call on this surface slow enough to need one.
It fires at least twice: once at 0 before anything is sent, so a bar
exists from the moment the request leaves rather than from whenever the
first chunk clears, and once at the end, so a bar cannot be left stopped
short of full by a transport whose last chunk report raced the response.
Asking for it changes the transport, and only where that is possible.
See this module’s doc: byte-level progress means XMLHttpRequest, and where
there is none (Node, a worker without it) the call stays on fetch and the
reports degrade to the two ends — sending, then sent. Nothing else differs:
same URL, same headers, same failures.
Parameters
Section titled “Parameters”progress
Section titled “progress”Returns
Section titled “Returns”void
parallel?
Section titled “parallel?”
optionalparallel?:UploadParallelOption
Cut the file up and send the pieces at once, instead of in one request.
On by default. false opts out, { partBytes, concurrency } tunes it.
What it buys is the difference between one connection’s throughput and the
link’s: a single request is bounded by its congestion window over the
round-trip time, so the further away the agent is the smaller a fraction of
the available bandwidth one request can use, and a recording is exactly the
body big enough for that to be the wait a person is sitting through. It is
also the only path here that can RETRY — see partsSettings for why the
single-request writers cannot.
It degrades rather than failing: a body that cannot be cut by byte (a
string), a file that fits in one part, or an agent deployed before the
/parts routes existed all send the file the ordinary way instead. So the
default does nothing where it would not have helped, and opting out is for a
caller who knows something about their own link that this does not.
workflow-upload-parts.ts carries the rest.
resume?
Section titled “resume?”
optionalresume?:boolean
Continue an upload already begun under this id, sending only the windows that are missing.
What it buys is the difference between resuming a recording and starting it over. Without it a second attempt at an id is REFUSED — which is the rule that makes a caller-chosen id safe, since nothing else stops one upload writing into another’s — so this is how a caller says the id is its own.
A transient failure needs no flag: this call re-enters itself. A round
that fails for a reason that looks like an outage is retried with the resume
already set, up to UPLOAD_RESUME_ATTEMPTS (see _upload-resume.ts, which
carries what “looks like an outage” excludes). So this option is for a
SEPARATE call against an id the caller already owns — the round after a pause,
a second submit of a form the person interrupted — and not for retrying.
Only the parts path can do it, and the store is what makes it safe: a part’s
rows are keyed by the offset it starts at, so re-sending one is writing the
same bytes to the same place. The windows already stored come from
UploadInfo.ranges, and an agent too old to report them re-sends the whole
file rather than leaving a hole.
The bytes must be the SAME FILE. Nothing here can check that — the id is a capability and the offsets are the caller’s contract — so a resume with a different file is a corrupted upload only its owner can read.
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
Abort the upload. Its own option rather than the client’s timeoutMs,
which is sized for a JSON round trip: a large file legitimately takes
minutes, and a deadline that cannot tell those apart cancels the one thing
on this surface that is expensive to redo.
optionaltype?:string
MIME type to store. Defaults to a Blob’s own type, else octet-stream.
UploadParallelOption
Section titled “UploadParallelOption”UploadParallelOption =
boolean|UploadPartsOptions
What UploadOptions.parallel accepts: true for the defaults, or the
settings to tune them.
UploadPartsOptions
Section titled “UploadPartsOptions”UploadPartsOptions =
object
How a caller tunes the fan-out.
Both fields have defaults sized on the constants’ own reasoning, and a caller
that just wants the speed passes parallel: true and never sees this type.
Properties
Section titled “Properties”concurrency?
Section titled “concurrency?”
optionalconcurrency?:number
Parts in flight at once. Defaults to 4 (UPLOAD_PART_CONCURRENCY).
partBytes?
Section titled “partBytes?”
optionalpartBytes?:number
Bytes per part. Defaults to 8 MiB (UPLOAD_PART_BYTES).
Rounded UP to a whole number of UPLOAD_CHUNK_BYTES, because a part starts at
a chunk boundary in the store and a size that is not a multiple of one would
put the next part’s start inside a stored chunk.
UploadProgress
Section titled “UploadProgress”UploadProgress =
object
How far an upload has got, as UploadOptions.onProgress reports it.
Properties
Section titled “Properties”fraction
Section titled “fraction”fraction:
number|undefined
loaded / total, clamped to 0..1 — the number a bar’s width IS, so no
caller divides and none has to guard the zero-byte body that would divide
to NaN and render as a bar of no width labelled NaN%.
Undefined exactly when UploadProgress.total is.
loaded
Section titled “loaded”loaded:
number
Bytes handed to the network so far.
total:
number|undefined
The body’s size, when it is knowable. Undefined for a body whose length the transport cannot state up front, which is the case a bar has to render as indeterminate rather than as empty.
UploadRef
Section titled “UploadRef”UploadRef =
object
A stored upload, as WorkflowApi.upload resolves it.
Properties
Section titled “Properties”complete
Section titled “complete”complete:
boolean
Whether every byte is in — always true for a call that resolved.
id:
string
The handle a run input carries.
name:
string
Filename as stored.
size:
number
Size in bytes.
type:
string
MIME type as stored.
url:
string
Absolute URL the bytes can be read back from, Range included.
WakeUpOptions
Section titled “WakeUpOptions”WakeUpOptions =
object
Options for WorkflowClient.wakeUp.
Properties
Section titled “Properties”correlationIds?
Section titled “correlationIds?”
optionalcorrelationIds?:string[]
Interrupt only the sleep() calls carrying these correlation ids. Omitted,
every pending sleep in the run is interrupted, which is what a “do it now”
button means.
WorkflowApi
Section titled “WorkflowApi”WorkflowApi =
object
Sealed
The calls the API offers — one method per route, and nothing beyond them.
The width is the constraint: a route needing more than a tool can do is the
signal to add a WorkflowClient method server-side, never to grow this
into an engine with reads of its own: this surface dispatches, it does not
query.
Methods
Section titled “Methods”cancel()
Section titled “cancel()”cancel(
runId,options?):Promise<boolean>
Stop a run, resolving whether this call is what ended it. A run that had already finished answers false rather than failing — two tabs pressing Stop is ordinary.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<boolean>
download()
Section titled “download()”download(
id,options?):Promise<Blob>
Read an upload’s BYTES, as a Blob — the other end of a run that PRODUCED
a file (stepWriteUpload stores it, the output carries the id). A Blob
rather than a URL because the byte route takes the same bearer every route
here does and neither <audio src> nor <a href> can send one;
downloadUpload carries the rest.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<Blob>
find()
Section titled “find()”find(
workflow,key,options?):Promise<WorkflowRunSnapshot[]>
Runs of workflow started with key, newest first.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
follow()
Section titled “follow()”follow(
runId,options?):AsyncIterable<WorkflowRunSnapshot>
Every snapshot of a run, until it settles — the call watch is the raw
material for.
import { createAgentClient } from "@alexkroman1/aai/workflow-api";
const agent = createAgentClient({ baseUrl: "https://agents.example/my-agent" });for await (const run of agent.follow("wrun_1")) console.log(run.status);The last value is the TERMINAL snapshot, and reaching it is what ends the
iteration, so a caller that only wants the answer keeps the last one it saw.
The two protocol rules a hand-written loop gets wrong are honoured inside:
the stream hands the client back with an idle frame after its own duration
cap (a run may sleep for hours) and this re-opens, and a stream that ends
with the run unsettled THROWS rather than looking like a run that finished.
There is no polling fallback, deliberately — an agent that does not serve the route fails here with its own sentence, and a caller who wants to poll instead is the caller WorkflowApi.watch exists for.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”AsyncIterable<WorkflowRunSnapshot>
followOutput()
Section titled “followOutput()”followOutput(
runId,options?):AsyncIterable<unknown>
Everything a run WRITES, in order, until it settles.
import { createAgentClient } from "@alexkroman1/aai/workflow-api";
const agent = createAgentClient({ baseUrl: "https://agents.example/my-agent" });for await (const chunk of agent.followOutput("wrun_1")) console.log(chunk);One read of the route is bounded by the tail it saw, so this re-opens from
the next unread chunk until the run is finished — which is the rule that
makes a single for await cover a live run’s whole log. Chunks are retained
with the run, so it is a replay as much as a tail and starts at the
beginning by default; fromIndex is ABSOLUTE, and the raw route’s negative
“last N” form is left on WorkflowApi.streamOutput because it names
no position a re-open could resume from.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”AsyncIterable<unknown>
get(
runId,options?):Promise<WorkflowRunSnapshot|undefined>
Read a run’s state. Resolves undefined for an unknown id.
Deliberately NOT generic on the output, even though a caller wants it typed:
a generic METHOD has to be implemented generically, which would make every
test double and every hand-written stub of this client generic too. The type
parameter belongs on whatever a caller states its expectation with —
useWorkflowRun<R> in the browser client, or a cast at the one place a
script reads output.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot | undefined>
list()
Section titled “list()”list(
options?):Promise<WorkflowSummary[]>
Declared workflows: name, description, and the input schema to render.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowSummary[]>
recent()
Section titled “recent()”recent(
workflow,options?):Promise<WorkflowRunSnapshot[]>
Runs of workflow, newest first, whatever key they carry.
The operator’s read where WorkflowApi.find is the app’s — a console
has no correlation key to ask about, and most runs carry none (a page holds
its own runId). Two methods rather than one nullable key, so a caller
meaning “this user’s runs” cannot silently widen to every user’s.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot[]>
start()
Section titled “start()”start(
workflow,input?,options?):Promise<string>
Start a run and resolve its id WITHOUT waiting for it — the point of the mechanism. Rejects when the name is not declared or the input fails the workflow’s schema, both of which are 400s carrying the reason.
key is a correlation handle the caller chooses, so the run can be found
again later without the id — a signed-in user, an upload, a device. Pass one
when the caller might be gone before the run finishes and you would rather
look it up than remember the id.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
input?
Section titled “input?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<string>
startAndWait()
Section titled “startAndWait()”startAndWait(
workflow,input?,options?):Promise<WorkflowRunSnapshot>
Start a run and resolve the FINISHED one — the synchronous call.
What a form or a shell script wants, and what WorkflowApi.start
deliberately is not: one request in, one result out, with no watch to wire
up. The agent holds the request open until the run settles or its own budget
expires, so a run that is still going when the wait runs out resolves
NON-terminal — check isTerminal, or keep the id and read it back later.
wait is clamped to MAX_WORKFLOW_WAIT_MS at both ends, by the same
function, so this can never be waiting on a request the agent already
answered.
Parameters
Section titled “Parameters”workflow
Section titled “workflow”string
input?
Section titled “input?”unknown
options?
Section titled “options?”Returns
Section titled “Returns”Promise<WorkflowRunSnapshot>
streamOutput()
Section titled “streamOutput()”streamOutput(
runId,options?):Promise<Response>
Open a server-sent-event stream of what the run has WRITTEN — its progress, as opposed to WorkflowApi.watch’s status transitions.
Resolves the raw Response for the same reason watch does: an agent
deployed before this route existed answers 404, which a caller has to be able
to see rather than have raised at it. Frames are chunk then done.
Chunks are retained with the run, so this is a replay as much as a live tail:
a caller that reloads gets the whole stream by default, and startIndex
(negative counts back from the end) is for a reader resuming from a known
position.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<Response>
upload()
Section titled “upload()”upload(
file,options?):Promise<UploadRef>
Store a file and resolve the handle a run input carries.
The other half of WorkflowDef.uploads: a workflow’s input is journaled and
replayed on every resume, so bytes may not travel in it — they go here once,
and the run carries UploadRef.id, which a step reads windows of with
stepReadUpload.
A File from an <input type="file"> needs no second argument: its own
name and type are what get stored. Anything else — a Blob, a
Uint8Array — should name the file it is, since a step’s failure messages
and the download link are all the name it will ever have.
One request for the whole body, so a file past MAX_WORKFLOW_UPLOAD_BYTES is
a 413 rather than a truncation; UploadOptions.onProgress draws a bar.
{ parallel: true } sends it as concurrent parts instead, which is what a
recording over a long link wants — see UploadOptions.parallel.
Parameters
Section titled “Parameters”options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadRef>
uploadInfo()
Section titled “uploadInfo()”uploadInfo(
id,options?):Promise<UploadInfo>
Read an upload’s record: its name, how much has ARRIVED, and complete.
What a page watches a streamed upload with. complete is the field to branch
on — a size that stopped growing means only that nothing arrived recently,
which a slow link and a dead client both produce.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadInfo>
uploadStream()
Section titled “uploadStream()”uploadStream(
id,file,options?):Promise<UploadRef>
Store a file under an id YOU chose, so a run can start before it is all in.
The counterpart of WorkflowApi.upload, and the difference is the order
it makes possible: upload answers with an id once the last byte is stored, so
a run that needs the id in its input has to wait for the whole upload. Here the
caller already has the id.
id must be 1-64 characters of letters, digits, - and _ (a
crypto.randomUUID() qualifies) and must not already exist — a second call on
one id is a 409, never an append.
{ parallel: true } applies here too, and composes with the ORDER this method
exists for: the run reads the contiguous prefix as the parts fill it in,
exactly as it reads a single streaming PUT.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”Returns
Section titled “Returns”Promise<UploadRef>
wake()
Section titled “wake()”wake(
runId,options?):Promise<number>
End a run’s sleep() early, resolving how many pending sleeps were
interrupted.
0 is an answer, not a failure — the run finished, was never sleeping, or is
gone. Same shape as WorkflowApi.cancel answering false, and for the
same reason: two tabs pressing “send it now” is ordinary.
WakeUpOptions.correlationIds narrows it to the waits declared with
those ids, which is the same bag ctx.workflows.wakeUp takes and reaches the
route’s repeatable ?correlationId=. Reach for it when the caller means one
particular wait rather than “everything this run is waiting on” — and note it
is the ONLY spelling that can end a hook’s approval deadline, since a bare
wake deliberately cannot (the journal filters a hookTimeout out of one).
An id that is blank, or longer than 256 characters, REJECTS here without a request being sent. The route answers 400 for both, and there is nothing a caller can do with that answer that it could not do with a rejection it never had to make a round trip for.
Parameters
Section titled “Parameters”string
options?
Section titled “options?”WakeUpOptions & WorkflowApiCallOptions
Returns
Section titled “Returns”Promise<number>
watch()
Section titled “watch()”watch(
runId,signal?):Promise<Response>
Open a server-sent-event stream of one run’s state.
Resolves the raw Response rather than parsed frames, because what a caller
needs to decide first is whether the agent SERVES this at all — an older
deploy answers 404 and the caller falls back to polling, which is a normal
path rather than an error.
Parameters
Section titled “Parameters”string
signal?
Section titled “signal?”AbortSignal
Returns
Section titled “Returns”Promise<Response>
WorkflowApiCallOptions
Section titled “WorkflowApiCallOptions”WorkflowApiCallOptions =
object
What every WorkflowApi call takes: an abort signal, and nothing else.
Properties
Section titled “Properties”signal?
Section titled “signal?”
optionalsignal?:AbortSignal
WorkflowApiClientOptions
Section titled “WorkflowApiClientOptions”WorkflowApiClientOptions =
object
What a client needs to know: which agent, on whose authority, and for how long.
Properties
Section titled “Properties”baseUrl
Section titled “baseUrl”baseUrl:
string
The AGENT’s base URL — https://agents.example/my-agent, with or without a
trailing slash. WORKFLOW_API_PREFIX is resolved under it, so a
caller never spells the prefix and the three call sites that used to
concatenate it cannot drift.
Required, and deliberately: location does not exist in this half of the
SDK, so “the page’s own origin” is a browser default and belongs with the
browser client (createWorkflowApi in @alexkroman1/aai-ui).
timeoutMs?
Section titled “timeoutMs?”
optionaltimeoutMs?:number
Per-request deadline, in ms. Absent means none, which is what a page with its own retry loop wants.
Worth setting for anything a human is waiting on, because a hung request
is not a failure: fetch carries no timeout of its own, so a request
issued while the platform is restarting or saturated never settles and no
error path, retry, or backoff ever runs. The one thing it must not bound is
the event stream — a healthy SSE connection IS a request that stays open and
says nothing for minutes — so WorkflowApi.watch is exempt, and the
two waiting paths get the run’s own wait budget added on top rather than
being cut in the middle of a wait the agent agreed to.
token?
Section titled “token?”
optionaltoken?:string
Bearer for an agent whose operator set AAI_WORKFLOW_API_TOKEN.
A page served to the public has nothing to put here and should not — it
would be readable in the bundle. This is for a programmatic caller: a
script, a cron job, aai workflow --token.
| undefined is explicit, and that is the whole point of it: under
exactOptionalPropertyTypes — which this repo and the scaffold both set —
token?: string REFUSES token: process.env.AAI_WORKFLOW_API_TOKEN, which
is the one line every caller writes. Absent and present-and-undefined mean
the same thing here (no bearer), so the type says so rather than making a
reader reach for a ! or a conditional spread.
WorkflowBody
Section titled “WorkflowBody”WorkflowBody<
I,R> = (input,ctx) =>Promise<R> |R
A workflow body: an ordinary async function of its input and a WorkflowContext.
There is no workflowId any more, and its absence is the point. Under the
Workflow DevKit this type carried one, attached by a compile-time transform, and
start() read it — so a body that the bundler plugin had not reached looked
perfectly valid at the declaration site and failed at the first start() with
MISSING_WORKFLOW_ID. An agent that builds, deploys, boots and answers the
phone but cannot start a run is a bad failure to design in. A workflow is now
identified by the key it is declared under in agent({ workflows }), which
cannot go missing because the declaration IS the registration.
The body is REPLAYED — see WorkflowContext for what that forbids.
Type Parameters
Section titled “Type Parameters”I = unknown
The body’s validated input.
R = unknown
What the body returns.
Parameters
Section titled “Parameters”I
Returns
Section titled “Returns”Promise<R> | R
WorkflowFollowOutputOptions
Section titled “WorkflowFollowOutputOptions”WorkflowFollowOutputOptions =
object
WorkflowApi.followOutput’s options: which channel, from which chunk.
Properties
Section titled “Properties”fromIndex?
Section titled “fromIndex?”
optionalfromIndex?:number
namespace?
Section titled “namespace?”
optionalnamespace?:string
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
WorkflowGetOptions
Section titled “WorkflowGetOptions”WorkflowGetOptions =
object
WorkflowApi.get’s options: an optional wait for the run to settle.
Properties
Section titled “Properties”signal?
Section titled “signal?”
optionalsignal?:AbortSignal
optionalwait?:number
WorkflowOutputOf
Section titled “WorkflowOutputOf”WorkflowOutputOf<
D> =Dextendsobject?Awaited<unknownextendsO?R:O> :never
A workflow’s OUTPUT type, for a page that polls its runs.
This is the end-to-end typing a static page would otherwise be missing.
useWorkflowRun<R> makes run.status === "completed" narrow to a typed
run.output, and without this the page has to name R by hand — restating a
shape the agent module already declares, with nothing checking the two agree.
It needs no build step and no generated .d.ts, because the reason a page
“cannot import the agent” does not survive contact with import type: a
type-only import is ERASED, so it drags no server graph into the browser
bundle.
Type Parameters
Section titled “Type Parameters”D
Example
Section titled “Example”export const transcribe = workflow({ input: …, output: transcriptSchema, run: transcribeFlow });
// client.tsx — `import type` is erased, so nothing server-side is bundled.import type { WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";import type { transcribe } from "./agent.ts";
const run = useWorkflowRun<WorkflowOutputOf<typeof transcribe>>(runId, { api });if (run?.status === "completed") console.log(run.output.text); // typedIt reads the declared SCHEMA first, and that is what breaks a cycle
Section titled “It reads the declared SCHEMA first, and that is what breaks a cycle”The DECLARATION is the better source of this type, and the worse one used to
be the only one. Deriving R from the body means typeof theDef needs the
body’s signature — while a body annotated WorkflowInputOf<typeof theDef>
needs typeof theDef, which is TS7022 reported against agent.ts. The
documented way out is to ANNOTATE the declaration, and an annotation whose
R comes from a schema (WorkflowDef<typeof digestInput, z.infer<typeof digestOutput>>) states the output type once, in the schema, rather than
naming it a second time by hand.
That annotated shape is also what the second reading gets WRONG, which is
the other half of this rewrite. D extends WorkflowDef<ToolInputSchema, infer R> is an assignability test over the whole def, and run’s input is a
function PARAMETER — so a def carrying an input schema is not assignable to
one taking the open Record<string, unknown>, and the conditional silently
fell to never. It is the same contravariance AnyWorkflowDef was
written for, reached by the other route, and it is why the test below matches
run as (input: never, ctx: never) => infer R — never is assignable to
every parameter type.
It matches a SHAPE, not a named declaration
Section titled “It matches a SHAPE, not a named declaration”Both readings test run’s signature structurally rather than naming
WorkflowDef, WorkflowBody or WorkflowContext. A reading answers the
same type either way — WorkflowDef.run IS (input: InferSchemaOutput<P>, ctx: WorkflowContext) => … — but a reading that names the declaration
carries it (and everything WorkflowContext reaches) into the contract of
every capability that publishes the reading, so a new member on the context
a body receives moved a PAGE’s type.
unknown extends O is how “declared nothing” is told from “declared a
schema”: a def with no output schema still HAS the optional property in its
type, carrying R — so the two readings agree, and the fallback only ever
fires for a def-shaped object that names no output at all.
Awaited because a body may be sync or async and the snapshot always holds
the settled value.
On @alexkroman1/aai/workflow-api only, unlike its two siblings: its reader
is a page. Both templates that name it are a client.tsx parameterizing
useWorkflowRun<…>, and a *_status tool wants WorkflowRunOf, which
composes this in already.
WorkflowRunBase
Section titled “WorkflowRunBase”WorkflowRunBase =
object
Fields every WorkflowRunSnapshot member carries, whatever its status.
Exported because it is part of a public type’s shape: WorkflowRunSnapshot
intersects it into every member, so TypeDoc’s treatWarningsAsErrors fails the
docs build for a type “referenced by a public signature but not exported” —
which is the rule working, not an inconvenience. Keeping the alias rather than
inlining the fields five times is what makes a field added here reach every
status at once.
Properties
Section titled “Properties”createdAt
Section titled “createdAt”createdAt:
number
When the run was created, as epoch ms.
optionalkey?:string
The correlation key WorkflowClient.start was given, when it was given one.
runId:
string
workflow
Section titled “workflow”workflow:
string
Key the workflow is declared under in agent({ workflows }).
WorkflowRunListOptions
Section titled “WorkflowRunListOptions”WorkflowRunListOptions =
object
WorkflowApi.find and WorkflowApi.recent’s options: how many runs to answer.
Properties
Section titled “Properties”limit?
Section titled “limit?”
optionallimit?:number
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
WorkflowRunSnapshot
Section titled “WorkflowRunSnapshot”WorkflowRunSnapshot<
R> =WorkflowRunBase&object|WorkflowRunBase&object|WorkflowRunBase&object|WorkflowRunBase&object
A run’s observable state, as WorkflowClient.get returns it.
Discriminated on status, so the field a status defines is present
exactly when that status holds: narrowing to "completed" gives a
non-optional output, and to "failed" a non-optional error. A flat object
with optional fields makes every consumer pay a cast — a page rendering a
result would write run.status === "completed" ? (run.output as Out) : undefined, re-asserting by hand both halves of what the type can say.
Type Parameters
Section titled “Type Parameters”R = unknown
The workflow’s own return type, when the caller named the
workflow (see WorkflowDef); unknown otherwise.
WorkflowRunStatus
Section titled “WorkflowRunStatus”WorkflowRunStatus =
"pending"|"running"|"completed"|"failed"|"cancelled"
Lifecycle of one workflow run.
pending— created, not yet picked up by the queue.running— executing, or suspended at asleep/hook waiting to resume.completed/failed/cancelled— terminal.
WorkflowStartAndWaitOptions
Section titled “WorkflowStartAndWaitOptions”WorkflowStartAndWaitOptions =
object
WorkflowApi.startAndWait’s options: WorkflowStartOptions plus
the wait budget, clamped to MAX_WORKFLOW_WAIT_MS at both ends.
Properties
Section titled “Properties”
optionalkey?:string
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
optionalwait?:number
WorkflowStartOptions
Section titled “WorkflowStartOptions”WorkflowStartOptions =
object
WorkflowApi.start’s options — a correlation key and a signal.
Not StartOptions, which is the same verb one side over:
ctx.workflows.start takes { key, notify } from a TOOL, where notify
has a session to speak into. A caller outside the agent has none, so this
one carries signal where that one carries notify; the two share only
key, and its meaning.
Properties
Section titled “Properties”
optionalkey?:string
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
WorkflowStreamOutputOptions
Section titled “WorkflowStreamOutputOptions”WorkflowStreamOutputOptions =
object
WorkflowApi.streamOutput’s options: which channel, from which chunk.
Properties
Section titled “Properties”namespace?
Section titled “namespace?”
optionalnamespace?:string
signal?
Section titled “signal?”
optionalsignal?:AbortSignal
startIndex?
Section titled “startIndex?”
optionalstartIndex?:number
WorkflowSummary
Section titled “WorkflowSummary”WorkflowSummary =
object
One declared workflow, as GET /workflows lists it.
Here rather than in host/ because both ends need it and only one of them is
a Node process: the API serves it, and a static page’s client renders a form
from it.
Properties
Section titled “Properties”description?
Section titled “description?”
optionaldescription?:string
The workflow’s own description, when it declared one.
inputSchema?
Section titled “inputSchema?”
optionalinputSchema?:unknown
JSON Schema for the run input, when the workflow declared one — what a page renders its form from. Converted at declaration-listing time rather than shipped as the Standard Schema itself, because the reader is a browser.
name:
string
Key the workflow is declared under in agent({ workflows }).
outputSchema?
Section titled “outputSchema?”
optionaloutputSchema?:unknown
JSON Schema for what a completed run answers with, when the workflow
declared an output — what a page renders its RESULTS from, the way
inputSchema is what it renders its form from.
Converted at declaration-listing time for the same stated reason: the reader is a browser, and a Standard Schema does not survive the wire.
The two are converted in opposite DIRECTIONS and the asymmetry is not an
oversight — see the converter in the runtime’s workflow-client.ts. An
input schema is described as what a caller may SEND (a .default() field
is optional); an output schema as what the run PRODUCES, which is the
parsed value, where that same field is always present.
uploads?
Section titled “uploads?”
optionaluploads?: readonlystring[]
Input properties that carry an upload id — see WorkflowDef.uploads.
Served alongside the schema because a form is rendered from BOTH: the schema says the property is a string, and this says the string is a file the page has to upload first.
Variables
Section titled “Variables”ClientConfigResponseSchema
Section titled “ClientConfigResponseSchema”
constClientConfigResponseSchema:z.ZodObject<{greeting:z.ZodOptional<z.ZodString>;name:z.ZodOptional<z.ZodString>;page:z.ZodEnum<{static:"static";voice:"voice"; }>;sessionUrl:z.ZodOptional<z.ZodString>; },z.core.$strip>
Body of GET /client-config. Unknown fields are stripped, so a response
from an older server still parses.
References
Section titled “References”SleepOptions
Section titled “SleepOptions”Re-exports SleepOptions
StepOptions
Section titled “StepOptions”Re-exports StepOptions
StepSchemaOptions
Section titled “StepSchemaOptions”Re-exports StepSchemaOptions
UploadInfo
Section titled “UploadInfo”Re-exports UploadInfo
UploadRange
Section titled “UploadRange”Re-exports UploadRange
WaitForOptions
Section titled “WaitForOptions”Re-exports WaitForOptions
WaitForSchemaOptions
Section titled “WaitForSchemaOptions”Re-exports WaitForSchemaOptions
WorkflowClient
Section titled “WorkflowClient”Re-exports WorkflowClient
WorkflowContext
Section titled “WorkflowContext”Re-exports WorkflowContext
WorkflowDef
Section titled “WorkflowDef”Re-exports WorkflowDef
WorkflowInputOf
Section titled “WorkflowInputOf”Re-exports WorkflowInputOf
WorkflowRunOf
Section titled “WorkflowRunOf”Re-exports WorkflowRunOf