For AI agents: the complete documentation index is available at https://flow.jig.md/llms.txt, the full documentation bundle is available at https://flow.jig.md/llms-full.txt, and this page is available as Markdown at https://flow.jig.md/spec/run-sdk.md.
DocumentationSpecificationPrerelease

#FLOW Run SDK/0

View Markdown

Status: prerelease SDK projection of FLOW Run/0.

This document fixes the public component-author interface for the Run/0 slice. It does not add wire behavior. When this document and Run/0 differ, Run/0 owns the protocol and this projection must be corrected.

#1. Surface and ownership

The TypeScript package root is @jigging/flow. The Python distribution is jiggy-flow, imported as jiggy.flow. These root modules expose Run SDK/0 only.

Both expose only:

handle
RunContext
RunResult
CallResult / Artifact
OperationError
JSON value types
attachment types
handler types
ChannelSender / ChannelReceiver / ChannelEndpoint / ChannelPair / ChannelBroadcast
ChannelContractIdentity

The TypeScript projection additionally names FlowCall, CallOptions, ChannelOptions, and ChannelCloseOptions. Python expresses the same values as keyword-only method arguments and uses ordinary task cancellation.

handle receives this Flow's flow/run invocation. run.call(...) invokes a declared local slot through flow/call and returns its complete CallResult, whether the host selects an ordinary Flow or a native implementation. Authors do not construct the host-supplied context. The initial single-operation profile accepts no method or operation selector.

Authors use this same API when their Flow is launched directly or as another Flow's dependency. The distinction between flow/run and flow/call belongs to the host/component exchange. It does not require a second child-handler API.

handle owns protocol stdin and stdout for the process and handles exactly one root Run. The TypeScript SDK captures its transport first, then replaces the global console with one backed by stderr. An imported library that reads the current global console during the handler is therefore safe. The Python SDK routes ordinary print() and sys.stdout output to stderr. Redirection remains installed after the one-shot call so later application output cannot become trailing protocol bytes.

Output from modules evaluated before handle, a console/stdout reference cached before handle, and raw writes to stdout or file descriptor 1 remain invalid protocol output. Output from a child process inheriting stdout remains invalid protocol output. Bare Run/0 implementations are responsible for keeping protocol stdout clean. The SDK never treats malformed protocol output as a log.

A TypeScript entrypoint may import only handle statically and dynamically import its application module from the handler. In that form the SDK installs console redirection before evaluating the later application graph. This includes its top-level console calls and any console methods it then caches. This is an authoring pattern, not a second SDK operation, and it does not make raw stdout or inherited child stdout valid.

Calling other protocol, resolver, host-configuration, provider, sandbox, administration, Agent, or graph APIs through this SDK is impossible because none are exposed.

#2. TypeScript

type JsonValue =
  | null
  | boolean
  | number
  | string
  | readonly JsonValue[]
  | { readonly [key: string]: JsonValue };

type RunResult = {
  readonly outcome: string;
  readonly output: JsonValue;
  readonly artifactResults?: Readonly<Record<string, Artifact>>;
};

interface Artifact {
  readonly reference: string;
}

type CallResult = {
  readonly outcome: string;
  readonly output: JsonValue;
  readonly artifacts?: Readonly<Record<string, Artifact>>;
};

interface FlowCall {
  readonly operationId: string;
  readonly slot: string;
  readonly intent?: string;
  readonly input: JsonValue;
  readonly channels?: Readonly<Record<string, ChannelEndpoint>>;
  readonly attachments?: Readonly<Record<string, {readonly read: Artifact} | {readonly create: true}>>;
}

interface RunContext {
  readonly input: JsonValue;
  readonly settings: Readonly<Record<string, JsonValue>>;
  readonly attachments: Readonly<Record<string, Attachment>>;
  readonly channels: Readonly<Record<string, ChannelEndpoint>>;
  readonly scratch: string;
  readonly deadlineUnixMs: number;
  readonly signal: AbortSignal;

  call(call: FlowCall, options?: { signal?: AbortSignal }): Promise<CallResult>;
  release(request: {operationId: string; artifact: Artifact}, options?: CallOptions): Promise<void>;

  channel(options: ChannelOptions & { delivery: 'broadcast' }, callOptions?: CallOptions):
    Promise<ChannelBroadcast>;
  channel(options?: ChannelOptions & { delivery?: 'direct' }, callOptions?: CallOptions):
    Promise<ChannelPair>;
  channel(options: ChannelOptions, callOptions?: CallOptions):
    Promise<ChannelPair | ChannelBroadcast>;
}

type RunHandler = (context: RunContext) => Promise<RunResult>;

declare function handle(handler: RunHandler): Promise<void>;

run.signal reports root cancellation. If a call-specific signal is already aborted when run.call is invoked, the returned promise rejects with an OperationError whose code is exactly CANCELLED. The same applies if that signal becomes aborted while the call is pending. The SDK sends request/cancel if the request reached the wire. This cancels the local wait promptly. It does not claim that remote work was undone.

Code which catches only to observe this cancellation must rethrow every other failure:

try {
  await run.call(call, { signal });
} catch (error) {
  if (!(error instanceof OperationError) || error.code !== "CANCELLED") {
    throw error;
  }
}

In particular, passing a call-specific signal does not convert an unrelated effect, operation, protocol, channel, validation, or programming failure into cancellation or success.

#3. Python

class RunResult(TypedDict):
    outcome: str
    output: JsonValue
    artifactResults: NotRequired[Mapping[str, Artifact]]


class CallResult(TypedDict):
    outcome: str
    output: JsonValue
    artifacts: NotRequired[Mapping[str, Artifact]]


class RunContext(Protocol):
    @property
    def input(self) -> JsonValue: ...

    @property
    def settings(self) -> Mapping[str, JsonValue]: ...

    @property
    def attachments(self) -> Mapping[str, Attachment]: ...

    @property
    def channels(self) -> Mapping[str, ChannelEndpoint]: ...

    @property
    def scratch(self) -> str: ...

    @property
    def deadline_unix_ms(self) -> int: ...

    async def call(
        self,
        *,
        operation_id: str,
        slot: str,
        input: JsonValue,
        intent: str | None = None,
        channels: Mapping[str, ChannelEndpoint] | None = None,
        attachments: CallAttachments | None = None,
    ) -> CallResult: ...

    async def release(self, *, operation_id: str, artifact: Artifact) -> None: ...

    async def channel(
        self,
        *,
        delivery: Literal["direct", "broadcast"] = "direct",
        schema: JsonValue = ...,
        contract: str | ChannelSlotContract | None = None,
    ) -> ChannelPair | ChannelBroadcast: ...


RunHandler = Callable[[RunContext], Awaitable[RunResult]]

def handle(handler: RunHandler) -> None: ...

handle owns and creates the process event loop, so it is a synchronous entrypoint and rejects use inside an already-running asyncio loop. Root cancellation cancels the handler task with ordinary asyncio.CancelledError. Cancelling a task awaiting run.call cancels that local wait and sends request/cancel if the request reached the wire.

#4. Values and snapshots

Public type annotations describe JSON-shaped values, but the wire accepts only bounded FLOW JSON/0. SDKs validate every value crossing the boundary. Invalid call arguments fail locally before a request is admitted. Invalid handler results become INVALID_RESULT.

Inbound values are isolated decoded snapshots. Each admitted outbound call is also snapshotted before asynchronous dispatch, so later caller mutation cannot change its wire meaning. The SDKs do not recursively freeze application containers. Readonly or frozen outer declarations are authoring guidance, not a new runtime object model.

Call fields are closed: operationId, slot, input and only optional intent, channels and attachments. Optional-key presence enters Run/0 identity. Omitted channels and an explicit empty mapping remain distinct. TypeScript explicitly present undefined optional fields reject. Python None means omission for its optional keyword arguments. Intent is advisory metadata, never application input, Agent instructions, provider arguments or authority.

The SDK never creates an operationId. Component code supplies a stable ID whose retry and deduplication meaning is defined by Run/0.

#5. Results and errors

run.call returns the complete CallResult, including its outcome and optional held artifacts. Artifact objects are not JSON/0 data. Retain domain information using {outcome: result.outcome, output: result.output} and explicitly select forwarded artifacts in the handler's artifactResults. SDKs never unwrap output or translate declared outcomes into exceptions. Domain outcomes such as blocked, limit or not-found remain normal data. Applications interpret them explicitly.

A handler forwarding a result must declare any custom outcomes it can itself return.

#Immutable artifact handoff

An Artifact is an opaque, participant-held immutable tree with a readonly reference for identity comparison, no path and no public constructor. Only objects projected from host replies or initial readonly attachments can be used by that Run. The host, not SDK provenance, checks every actual right. Copied objects, guessed strings and objects from another Run cannot manufacture a held object.

Released refs may serialize for exact replay. The host must distinguish replay from a new reader and reject new use without rights. Artifact objects are not domain JSON values.

TypeScript attachment views retain path and access, with optional artifact: Artifact on a readonly view. Python uses the same keys in its Attachment typed dictionary (run.attachments["source"]["artifact"]). CallAttachments is a mapping to exactly {"read": Artifact} or {"create": Literal[True]}. Readonly references can be delegated onward:

const made = await run.call({
  operationId: "produce:1", slot: "producer", input: {},
  attachments: { files: { create: true } },
});
const files = made.artifacts!.files!;
const checked = await run.call({
  operationId: "evaluate:1", slot: "evaluator", input: {},
  attachments: { candidate: { read: files } },
});
return { outcome: "done", output: checked.output,
  artifactResults: { final: files } };
made = await run.call(operation_id="produce:1", slot="producer", input={},
                      attachments={"files": {"create": True}})
files = made["artifacts"]["files"]
checked = await run.call(operation_id="evaluate:1", slot="evaluator", input={},
                         attachments={"candidate": {"read": files}})
return {"outcome": "done", "output": checked["output"],
        "artifactResults": {"final": files}}

The producer declares files: read-write, the evaluator candidate: read, and the coordinator declares artifactResults: ["final"]. A blocked branch may select no artifacts. Domain result schemas still see only {outcome, output}. Duplicate results preserve reference identity and do not create a new grant. Unknown ports and contract requirements remain host checks.

Ordinary root cleanup automatically releases live rights, including late cancelled replies. Authors need no cleanup loop.

Advanced early release is await run.release({operationId:"release:1", artifact: files}) or await run.release(operation_id="release:1", artifact=files). Release settlement survives waiter cancellation and prevents additional reads or forwarding after acknowledgement. Accepted sibling readers continue. A replayed old result does not resurrect the released object. An unsupported optional release operation raises OperationError("UNAVAILABLE"). Automatic cleanup still applies.

Operational failure raises:

OperationError(code, message?, details?)

Authors inspect code, message, and details. They do not branch on the human message. In Python, omitted details are represented by None.

The thirteen Run/0 operational codes may cross the wire. PROTOCOL_ERROR and CHANNEL_LOST are local-only classifications and are never serialized as operational errors. An unhandled valid wire-visible OperationError from the root handler is preserved. An ordinary exception, a local-only code, or malformed error metadata becomes EXECUTION_FAILED.

A standard JSON-RPC error returned for a correctly emitted call is a fatal peer incompatibility. The SDK closes the channel as PROTOCOL_ERROR rather than presenting it as an ordinary call failure.

#6. Completion and cancellation

The handler may issue concurrent calls. The SDK continues reading while responses arrive in any order and serializes writes. At most 64 component-originated requests are live on the wire. It emits at most 65,536 requests during the channel lifetime. A later call fails locally with OperationError code RESOURCE_EXHAUSTED and emits no request.

With channels, ordinary admission reserves at least one live slot and enough remaining lifetime request IDs for every allocated or pending-allocation endpoint's settlement. Settlement stays inside the same total wire ceilings.

Handler return closes admission. The SDK does not detach calls. It requests cancellation for remaining owned work and waits for each wire request to settle. It refuses root success when the handler abandoned a live call.

A cancelled local waiter retains an internal wire tombstone until its response or channel termination. This prevents misclassification of a late response as an unknown ID. An explicitly cancelled and observed local wait is not abandonment. The handler may return a result, but the SDK sends that root result only after the cancelled wire request settles.

Normal try/catch, try/except, allSettled and gather remain sufficient for settled recoverable failures. There is no acknowledgement operation or global settled-failure ledger. An ignored already-settled failure can escape detection. That does not waive live ownership, root cancellation, fatal transport loss, uncertain dispatch or failed cleanup. A conclusively cleaned child failure can remain recoverable in its healthy parent.

Handler settlement and invalid result checks precede implicit writer sealing. An activated unfinished receiver is abandonment unless it reached terminal failure or explicit disposal. Previously explicitly sealed intervals are not retracted by later producer failure. The host alone knows actual transfer and source state and performs eligible implicit sealing. SDKs never infer moved rights from final call outcomes. Retained read/disposal settlements finish before submitting the invocation terminal.

Once an endpoint has been offered in a call's channel map, its terminal disposition is host-owned. This includes cases where admission fails or the callee fails before sending data. Callers must not infer transfer from receiving a message or try to dispose all offered endpoints after failure. They still dispose active receivers they kept locally. The SDK tracks offered rights separately.

The deadline is exposed as context, not implemented as an SDK timer. The host is responsible for enforcing it and terminating an uncooperative process.

#7. Minimal root examples

TypeScript:

import { handle } from "@jigging/flow";

await handle(async (run) => ({ outcome: "done", output: run.input }));

Python:

from jiggy.flow import RunContext, RunResult, handle


async def run(context: RunContext) -> RunResult:
    return {"outcome": "done", "output": context.input}


handle(run)

These examples require only the root flow/run operation. Availability of declared slots is host configuration. Calling an unavailable slot returns UNAVAILABLE.

#8. Call examples

A handler can invoke a declared slot with the following TypeScript:

import { handle } from "@jigging/flow";

await handle(async (run) => {
  const child = await run.call({
    operationId: "research:1",
    slot: "research",
    input: run.input,
  });
  return { outcome: child.outcome, output: child.output };
});

The equivalent Python is:

from jiggy.flow import RunContext, RunResult, handle


async def run(context: RunContext) -> RunResult:
    child = await context.call(
        operation_id="research:1",
        slot="research",
        input=context.input,
    )
    return {"outcome": child["outcome"], "output": child["output"]}


handle(run)

When a host binds research, JSON/0 input and any explicitly mapped channel or attachment rights cross through this SDK operation. The complete CallResult returns separately, with opaque artifact objects outside domain JSON. Child context, authorization, and resource policy belong to the host. The SDK creates no implicit inheritance.

The caller-supplied operationId retains all Run/0 join, conflict, cancellation, and uncertainty semantics. Uncertain dispatch is not automatically replayed. The SDK creates no separately addressable child history, administration, scheduler, catalogue, resolver, or Agent-specific surface.

A host may impose a lower child-concurrency limit and report RESOURCE_EXHAUSTED. The Run/0 request-lifetime limit still applies.

The implementations live under packages/flow-sdk/ and packages/jiggy-flow/. The shared executable seed is conformance/run-0/.

#9. Channel projection

TypeScript names the following interfaces. Python exposes corresponding protocols. Receivers have start_sequence, aclose(), __anext__() and async context management. Python channel options are keyword-only arguments.

interface ChannelContractIdentity {
  readonly id: string;
  readonly version: string;
  readonly digest: string;
}
type ChannelOptions = {
  readonly schema?: JsonValue;
  readonly contract?: string | { readonly slot: string; readonly channel: string };
} & ({ readonly delivery?: 'direct' } | { readonly delivery: 'broadcast' });
interface ChannelCloseOptions extends CallOptions {
  readonly error?: 'LAGGED';
}
interface ChannelSender {
  readonly direction: 'send';
  readonly delivery: 'direct' | 'broadcast';
  readonly contract?: ChannelContractIdentity;
  send(value: JsonValue, options?: CallOptions): Promise<void>;
  close(options?: ChannelCloseOptions): Promise<void>;
}
interface ChannelReceiver extends AsyncIterableIterator<JsonValue> {
  readonly direction: 'receive';
  readonly delivery: 'direct' | 'broadcast';
  readonly contract?: ChannelContractIdentity;
  readonly startSequence: number;
  next(options?: CallOptions): Promise<IteratorResult<JsonValue>>;
  close(options?: CallOptions): Promise<void>;
}
type ChannelEndpoint = ChannelSender | ChannelReceiver;
interface ChannelPair {
  readonly send: ChannelSender;
  readonly receive: ChannelReceiver;
}
interface ChannelBroadcast {
  readonly send: ChannelSender;
  subscribe(options?: CallOptions): Promise<ChannelReceiver>;
}

Python absent contract identity is None. schema and contract are mutually exclusive, including explicit schema: true. Endpoint references remain private SDK/host machinery, never ordinary input data. Literal direct creation returns ChannelPair. Literal broadcast returns ChannelBroadcast in both SDKs. Python supplies matching typing overloads and await source.subscribe().

Only the creator retains subscribe. Transferring source.send leaves that authority with its creator. Each subscription gets a separate reader with an immutable starting sequence. Subscribe before producer dispatch when a consumer requires the beginning. Late readers receive only a suffix, not retained history.

An allocated subscription is active work even before its first read and must finish or be disposed. Cancelling creation or subscription retains settlement and disposes late grants. Failed cleanup cannot be recovered as success.

Broadcast sends do not wait for readers. A slow reader fails locally with LAGGED. A receiver-schema failure is likewise local. Both are distinct from a source/writer error, which aborts unsealed output for all readers. The caller still awaits execution independently and handles recoverable channel errors with ordinary language constructs.

await sender.close({error: 'LAGGED'}), or Python await sender.close(error="LAGGED"), declares incomplete output using ordinary writer authority. Clean close() is unchanged. Abnormal close may settle a source with pending sends. Those send operations receive their own failures. The close acknowledgement is observation evidence, never execution evidence.

It wakes active receivers with the sticky stream error without cancelling the work. Repeated same-cause closes join settlement. A previously requested clean end cannot be rewritten as failure. Cancellation of a close waiter retains its settlement as before. Other producer error values are rejected locally.

There is one iterator and one pending read per receiver, without prefetch. TypeScript iterator return() disposes on early loop exit. Python early exit requires async with receiver or explicit aclose() in finally. A bare async for break is insufficient. Fully exhausting either iterator is enough.

Cancelling an active read starts retained receiver disposal. An uncancelled close joins that disposal and every prior read/tombstone, exposing the first previously unexposed terminal cause. A cause received internally after its public read was cancelled has not yet been exposed. Repeated close suppresses that same cause only after a public operation raised/rejected with it. Cancelling a close waiter does not cancel settlement or lose its later cause.

Ordinary language recovery applies. No acknowledgement or query operation is needed.

Local failure to start a read is an operation failure, not proof that its endpoint ended. A fatal current-connection error remains fatal even when caught. Sender acceptance, receiver end and the separate execution result retain their different meanings under Run/0.

An application must start producer work before awaiting its first message. In Python, assigning a coroutine alone does not start it. gather starts both coroutines below and retains both outcomes:

updates = await run.channel(contract={"slot": "agent", "channel": "events"})

async def invoke():
    try:
        return await run.call(
            operation_id="answer", slot="agent", input=run.input,
            channels={"events": updates.send},
        )
    except (Exception, asyncio.CancelledError):
        # Rejected admission may leave no producer to end the stream.
        try:
            await updates.receive.aclose()
        except (Exception, asyncio.CancelledError):
            pass  # Preserve the execution failure.
        raise

async def observe():
    try:
        async with updates.receive:
            async for value in updates.receive:
                print(value, flush=True)
    except OperationError as error:
        if error.code not in {"LAGGED", "DISCONNECTED"}:
            raise
        print("Progress delivery was incomplete.", flush=True)

execution, observed = await asyncio.gather(invoke(), observe(), return_exceptions=True)
if isinstance(execution, BaseException):
    raise execution
if isinstance(observed, BaseException):
    raise observed
answer = execution

This excerpt assumes an admitted Agent-like slot and matching package-local channel contract. Application code interprets/filter values and the actual final answer. Neither that slot's meaning nor a logging sink is part of FLOW.