20 KiB
Python Workflow Client Design
Status
Approved in conversation on 2026-08-30. This document specifies the public Python object model; it does not authorize implementation until its companion implementation plan is reviewed.
Purpose
Add a public wf_client package for humans, IPython sessions, and AI agents
that have Python execution available. The package reconstructs workflow API
responses into useful Python objects, supports local graph authoring with
remote capabilities, saves immutable workflow versions through the existing
API, and operates deployments and runs without exposing JSON-RPC payloads.
This is not a new workflow backend. It is a deep Python interface over the
existing workflow API surface, HTTP JSON-RPC adapter, domain models, and
WorkflowBuilder.
Design Principles
Appis the only connection entry point users need to learn.- Network operations are async. A synchronous facade is allowed only if it can be implemented without owning or nesting an event loop.
- Local graph mutations are synchronous; remote discovery, saving, deployment, and execution are async.
- JSON serialization exists only at the transport seam. Public objects contain validated domain values, not unchecked response dictionaries.
- Existing
wf_core,wf_artifacts, andwf_authoringmodels remain the source of truth.wf_clientmust not create parallel workflow semantics. - Workflow artifacts remain immutable. Editing creates a mutable graph seeded from an exact artifact version.
- Deployments remain explicit when source selection matters and become conditional ceremony when no binding choice exists.
- Capability nodes and native subgraphs remain distinct authoring operations.
- Draft workspaces do not appear in the public
wf_clientinterface.
Hypothetical Usage
This section is the intended experience. Names may tighten during implementation, but the lifecycle and amount of required ceremony are part of the design contract.
Connect and use one capability
from wf_client import App
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
search = await app.capability("app.default.search")
print(search.input_schema)
result = await search(query="durable workflow systems")
print(result.outcome)
print(result.output)
search is a validated Python object, not an RPC response dictionary. Calling
it validates input locally, uses the workflow capability operation, validates
the response, and returns a CapabilityResult.
Build, save, and run a workflow
from pydantic import BaseModel, Field
from wf_authoring import input_from, input_path, output_to, state_path
from wf_client import App
class ReportInput(BaseModel):
topic: str
class ReportState(BaseModel):
sources: list[str] = Field(default_factory=list)
class ReportOutput(BaseModel):
sources: list[str]
app = App.from_http_jsonrpc("http://localhost:8765/rpc")
search = await app.capability("app.default.search")
graph = app.new_workflow(
"report",
input_schema=ReportInput,
state_schema=ReportState,
output_schema=ReportOutput,
)
searched = graph.use(
search,
id="search",
input=[input_from(input_path("topic"), "query")],
output=[output_to("sources", state_path("sources"))],
)
done = graph.end("ok", id="end_ok")
graph.set_entry_point(searched)
graph.connect(searched, "ok", done)
graph.set_output([input_from(state_path("sources"), "sources")])
report_v1 = await graph.save(version=1, title="Research report")
run = await report_v1.run({"topic": "durable workflow systems"})
print(run.status)
print(run.output)
graph is an EditableWorkflow, which subclasses WorkflowBuilder. Existing
builder operations such as use(), when(), choose(), match(),
foreach(), interrupt(), end(), connect(), and set_entry_point() remain
available directly. wf_client adds remote-capability use, provenance,
lossless workflow output bindings, and save().
The convenient artifact.run() form is available only when source bindings
are already unambiguous. It never chooses between accounts.
Bind an environment explicitly
production = await report_v1.deploy(
"report.production",
bindings={
"app.default": "company.production",
},
)
validation = await production.validate()
if not validation.runnable:
for diagnostic in validation.diagnostics:
print(diagnostic.message)
else:
run = await production.run({"topic": "durable workflow systems"})
Artifacts describe logical source requirements. Deployments select the actual configured sources for an environment. Built-in platform-only workflows need little or no deployment ceremony.
Edit an immutable saved version
report_v2_graph = await app.edit_workflow("report", version=1)
summarize = await app.capability("app.default.summarize")
summarized = report_v2_graph.use(summarize, id="summarize")
report_v2_graph.set_route("search", "ok", summarized)
report_v2_graph.connect(summarized, "ok", "end_ok")
report_v2 = await report_v2_graph.save(version=2)
Version 1 remains unchanged. edit_workflow() validates the artifact and its
plan, then seeds the same builder methods with every schema, node, edge,
binding, outcome, and subgraph reference preserved.
Reuse a saved workflow as a native subgraph
parent = app.new_workflow(
"weekly_digest",
input_schema=DigestInput,
state_schema=DigestState,
output_schema=DigestOutput,
)
report_step = parent.subgraph(
report_v2,
input=[input_from(input_path("topic"), "topic")],
output=[output_to("sources", state_path("report_sources"))],
)
An ordinary remote capability uses graph.use(capability). An immutable saved
workflow uses graph.subgraph(artifact). The distinction remains visible
because their dependency and runtime semantics differ.
Resume an interrupted run
run = await report_v2.run({"topic": "durable workflow systems"})
if run.interrupt is not None:
print(run.interrupt.request)
run = await run.resume({"approved": True})
for frame in (await run.trace(limit=25)).frames:
print(frame.node_id, frame.outcome)
Rich representations may summarize these objects in IPython, but displaying an object never performs I/O or executes a capability.
Public Package
src/wf_client/
├── __init__.py
├── app.py
├── authoring.py
├── capabilities.py
├── codec.py
├── deployments.py
├── errors.py
├── protocols.py
├── runs.py
└── workflows.py
Responsibilities:
app.py: connection construction and top-level discovery.protocols.py: the narrow internal port consumed by rich client objects.codec.py: wire payload to existing domain-model validation.capabilities.py: remote capability inspection and invocation.authoring.py: editable graph composition overWorkflowBuilder.workflows.py: immutable artifact and editable-workflow lifecycle.deployments.py: deployment validation and execution.runs.py: run refresh, trace, interrupt, and resume.errors.py: stable structured Python exceptions.
The existing RpcWorkflowApiClient is the production HTTP adapter for the
internal port. Tests use an in-memory adapter over the process-local workflow
API. Transport operation strings do not appear outside adapters.
Python Protocols
The following declarations describe the intended public behavior. Concrete classes may contain private state and helper methods not shown here.
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol, Self
type JsonObject = dict[str, Any]
type JsonSchema = JsonObject
type DriftPolicy = Literal["block", "warn", "allow"]
@dataclass(frozen=True, slots=True)
class ArtifactRef:
artifact_id: str
version: int
@dataclass(frozen=True, slots=True)
class CapabilityRef:
source: str
capability_key: str
class App:
@classmethod
def from_http_jsonrpc(
cls,
url: str,
*,
timeout_seconds: float = 30.0,
) -> Self: ...
async def capability(self, name: str) -> RemoteCapability: ...
async def capabilities(
self,
*,
query: str | None = None,
source_id: str | None = None,
limit: int = 50,
) -> Page[CapabilitySummary]: ...
def new_workflow(
self,
name: str,
*,
input_schema: SchemaLike,
state_schema: StateSchemaLike,
output_schema: SchemaLike,
outcomes: Sequence[str] = ("ok",),
) -> EditableWorkflow: ...
async def workflow(
self,
artifact_id: str,
*,
version: int,
) -> WorkflowArtifact: ...
async def edit_workflow(
self,
artifact_id: str,
*,
version: int,
) -> EditableWorkflow: ...
async def deployment(self, deployment_id: str) -> Deployment: ...
async def run(self, run_id: str) -> Run: ...
App.from_http_jsonrpc() configures a transport adapter but does not perform
network I/O. Returned objects retain the internal client port so their remote
methods do not require callers to pass App repeatedly.
Remote capability
class RemoteCapability:
ref: CapabilityRef
qualified_name: str
description: str | None
input_schema: JsonSchema
output_schema: JsonSchema
outcomes: tuple[str, ...]
is_async: bool
async def __call__(
self,
payload: Mapping[str, Any] | None = None,
/,
**fields: Any,
) -> CapabilityResult: ...
async def call(
self,
payload: Mapping[str, Any],
*,
deployment_id: str | None = None,
) -> CapabilityResult: ...
@dataclass(frozen=True, slots=True)
class CapabilityResult:
outcome: str
output: JsonObject | None
diagnostics: tuple[Diagnostic, ...]
The callable shorthand accepts one positional mapping or keyword fields, never
both. The explicit call() method carries advanced client options so a real
capability input field named deployment_id remains unambiguous. Inputs are
validated against the inspected capability schema before transport. Output and
outcome are validated against the inspected contract after transport.
RemoteCapability is not a local NodeSpec: it has no process-local handler.
The authoring adapter consumes its reference and schema contract directly.
Editable workflow
class EditableWorkflow(WorkflowBuilder):
name: str
based_on: ArtifactRef | None
def use(
self,
capability: RemoteCapability,
*,
id: str | None = None,
input: Sequence[StepInputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
desc: str | None = None,
) -> NodeUse: ...
def subgraph(
self,
workflow: WorkflowArtifact,
*,
id: str | None = None,
input: Sequence[StepInputBindingArg] | None = None,
output: Sequence[OutputBindingArg] | None = None,
desc: str | None = None,
) -> SubgraphNode: ...
def set_entry_point(self, step: StepRef) -> None: ...
def connect(self, source: StepRef, outcome: str, target: StepRef) -> None: ...
def set_route(
self,
source: StepRef,
outcome: str,
target: StepRef,
) -> None: ...
def remove_route(self, source: StepRef, outcome: str) -> None: ...
def remove_step(self, step: StepRef) -> None: ...
def set_output(
self,
bindings: Sequence[StepInputBindingArg],
) -> None: ...
def validate(self) -> ValidationReport: ...
def compile(self) -> Workflow: ...
async def save(
self,
*,
artifact_id: str | None = None,
version: int,
title: str | None = None,
description: str | None = None,
) -> WorkflowArtifact: ...
EditableWorkflow subclasses WorkflowBuilder from the separate wf_client
package. WorkflowBuilder stays transport-free, while the subclass inherits
its full authoring interface and adds remote-capability adaptation, artifact
provenance, and saving through the connected client port. Do not copy builder
methods into a parallel wrapper and do not use dynamic __getattr__
delegation; both approaches would create typing and drift problems.
Seeded editing also requires focused set_route(), remove_route(), and
remove_step() mutations. set_route() replaces the unique edge for one
source/outcome pair rather than appending a duplicate. remove_step() rejects
removal while routes still reference the step, so callers must deliberately
rewire or remove those routes first. These are local builder operations, not
draft API calls.
set_output() must first become a lossless WorkflowBuilder operation.
compile() must preserve workflow output bindings. This repair is required
before artifact editing is exposed.
use() accepts RemoteCapability; subgraph() accepts a workflow artifact.
These methods are intentionally not overloaded into one operation because a
capability call and a native subgraph boundary have different execution,
dependency, and trace semantics.
Workflow artifact
class WorkflowArtifact:
ref: ArtifactRef
title: str
description: str | None
workflow: Workflow
required_capabilities: tuple[RequiredCapability, ...]
workflow_dependencies: Mapping[str, int]
def inspect(self) -> Workflow: ...
def edit(self) -> EditableWorkflow: ...
async def deploy(
self,
deployment_id: str,
*,
bindings: Mapping[str, str] | None = None,
drift_policy: DriftPolicy = "block",
) -> Deployment: ...
async def run(
self,
input: Mapping[str, Any],
*,
deployment_id: str | None = None,
bindings: Mapping[str, str] | None = None,
drift_policy: DriftPolicy = "block",
) -> Run: ...
edit() is local because the artifact already holds its validated workflow.
It seeds a new editable workflow without mutating the artifact.
Convenience run() follows strict policy:
- Use
deployment_idwhen supplied. - Otherwise use an existing unambiguous default deployment if the backend exposes one.
- Otherwise create or reuse an implicit deployment only when every dependency is platform-provided or has one unambiguous concrete binding.
- Otherwise raise
DeploymentRequiredwith unresolved logical sources and candidate bindings.
The client never guesses between accounts or environments.
Deployment and run
class Deployment:
deployment_id: str
artifact: ArtifactRef
bindings: Mapping[str, str]
drift_policy: DriftPolicy
diagnostics: tuple[Diagnostic, ...]
runnable: bool
async def validate(self) -> DeploymentValidation: ...
async def run(self, input: Mapping[str, Any]) -> Run: ...
class Run:
run_id: str
deployment_id: str
status: Literal["running", "interrupted", "completed", "failed"]
outcome: str | None
output: JsonObject | None
interrupt: Interrupt | None
diagnostics: tuple[Diagnostic, ...]
async def refresh(self) -> Self: ...
async def resume(self, response: Mapping[str, Any]) -> Self: ...
async def trace(self, *, start: int = 0, limit: int = 25) -> TracePage: ...
Methods that change remote lifecycle state return refreshed immutable snapshots. They do not mutate a Python object behind the caller's back.
Data Flow
Capability discovery:
App.capability(name)
-> client port.inspect_capability(name)
-> JSON-RPC adapter
-> wire response validation
-> RemoteCapability
Authoring and saving:
EditableWorkflow local mutations
-> WorkflowBuilder.compile()
-> core Workflow validation
-> JSON-compatible plan at the adapter seam
-> workflow.artifacts.create_from_plan
-> WorkflowArtifact reconstruction
Editing:
App.workflow(ref)
-> inspect artifact
-> WorkflowArtifact model validation
-> Workflow model validation from artifact.plan
-> WorkflowArtifact.edit()
-> lossless EditableWorkflow seeded from Workflow
Serialization Boundary
HTTP JSON-RPC necessarily serializes requests and responses. The client must localize that fact:
public/domain object
<-> codec using existing Pydantic/domain models
<-> transport DTO
<-> JSON-RPC adapter
The current RPC mixins use static cast() calls over unchecked dictionaries.
The new package must not repeat that pattern. The adapter or codec validates
each operation result before a public object is constructed. Invalid server
responses raise InvalidResponse with the operation name and validation
details.
Error Model
wf_client exposes structured exceptions rooted at WorkflowClientError:
class WorkflowClientError(Exception): ...
class TransportError(WorkflowClientError): ...
class ProtocolError(WorkflowClientError): ...
class InvalidResponse(ProtocolError): ...
class CapabilityNotFound(WorkflowClientError): ...
class ArtifactNotFound(WorkflowClientError): ...
class ArtifactVersionConflict(WorkflowClientError): ...
class DeploymentRequired(WorkflowClientError): ...
class DeploymentNotRunnable(WorkflowClientError): ...
class ValidationFailed(WorkflowClientError): ...
class RevisionConflict(WorkflowClientError): ...
The HTTP adapter preserves the server error code, structured data, and repair
information. Rich client objects translate only stable workflow errors; unknown
server codes remain inspectable ProtocolError instances.
IPython Representation
Rich objects provide concise repr() output. _repr_html_() is allowed for
capabilities, workflows, deployments, and runs when it displays only bounded,
already-loaded state. Representation must never trigger network I/O, execute a
capability, expose secrets, or fetch an unbounded trace.
Draft Boundary
wf_client does not import draft models or expose draft methods. Direct Python
authoring compiles a complete workflow and saves it through the existing
artifact-from-plan operation.
Making draft support uninitialized by default is a separate server-composition
slice. Today WorkflowApi constructs draft modules unconditionally, durable
context validation requires a draft store, and the JSON-RPC app always
registers draft methods. That follow-up must make draft storage, domain modules,
and RPC registration opt-in without weakening artifact, deployment, or run
durability.
Testing Strategy
Tests exercise the same public interface callers use:
- Contract tests run
Appagainst an in-memory client-port adapter. - HTTP integration tests run
App.from_http_jsonrpc()against the local ASGI JSON-RPC application. - Capability tests cover inspection, schema validation, direct calls, and graph use.
- Round-trip tests require exact preservation of schemas, nodes, edges, step bindings, workflow output bindings, outcomes, and subgraph references across artifact inspection, editing, compilation, and saving.
- Deployment tests cover implicit platform-only execution, explicit bindings, ambiguous binding rejection, drift diagnostics, and run creation.
- Run tests cover completed output, failure, interrupt inspection, resume, and bounded trace reads.
- Error tests prove structured server errors survive the HTTP adapter.
- Representation tests prove
repr()and_repr_html_()perform no I/O and redact bounded fields where necessary.
Delivery Slices
- Repair lossless builder workflow-output and
Workflowreconstruction. - Add the internal client port, codecs, and structured errors.
- Add
Appand callableRemoteCapability. - Add
EditableWorkflowand artifact save/inspect/edit round trips. - Add
Deployment,Run, and strict convenience execution. - Add bounded IPython representations and user documentation.
- Separately make draft server composition opt-in and disabled by default.
Each slice must be independently tested and preserve the existing low-level RPC adapter for CLI and compatibility callers.
Out of Scope
- Scheduling or cron semantics.
- Deleting draft implementation.
- A synchronous facade that manages event loops.
- Dynamic Python source registration.
- Generating Python classes from arbitrary JSON Schema.
- UI authoring changes.
- Changing workflow runtime execution semantics.
- Automatically choosing among multiple concrete source accounts.