Secondary Workflows¶
A workflow is an optional feature that plugs into Orb without adding feature logic to the core turn pipeline. It is a Python record in a process-local registry, plus any hooks, state, attachments, and frontend code it needs.
Built-in examples are prose_rewriter, tts, image_gen, and
format_consistency.
What a workflow can do¶
A workflow may:
- add work before or after a generated turn;
- expose a conversation-scoped action or a conversation-less query;
- accept a file the user uploads for a character, such as a voice clip;
- produce an attachment for a message and later regenerate, reroll, rehydrate, activate, or delete it;
- keep state at conversation, message, character, or global config scope;
- register frontend cards, buttons, widgets, SSE handlers, audio, text effects, or click actions.
The framework owns registration, routing, persistence, locks, and common UI chrome. The workflow owns its feature logic.
Where things live¶
Backend¶
| Path | Purpose |
|---|---|
backend/workflows/registry.py |
Workflow records, subscriptions, plug-in discovery, lookups, and state access |
backend/workflows/contracts.py |
Hook types, context dataclasses, and ToolSpec |
backend/workflows/toolkit.py |
Stable imports for workflow authors |
backend/prompting/tool_catalog.py |
Ordered tool lookup and workflow-tool registration |
backend/workflows/attachment_cache.py |
Attachment storage, variants, budget, and eviction |
backend/workflows/__init__.py |
Plug-in discovery and host-adapter hook bindings |
backend/pipeline/workflow_bridge.py |
Pipeline hook dispatch and attachment staging |
backend/api/routes/workflows.py |
Workflow and attachment routes |
Each workflow is a package such as backend/workflows/tts/, named for its id.
Code under backend/workflows/<id>/ is a plug-in slice. It may import its own
package and backend.workflows.toolkit, but not other framework modules,
application layers, or peer workflows. Root modules directly under
backend/workflows/ are host adapters and own the integration with prompting,
inference, persistence, and the pipeline. The toolkit must be consumed through
explicit names in its literal __all__; wildcard imports, importing the module
object, and private names are rejected. The backend layer checker enforces this
boundary.
What belongs in analysis/ and what belongs in the workflow¶
analysis/ answers questions about text that more than one consumer asks.
A workflow owns the policy it applies to those answers: which reading wins,
what to change, and what to report.
format_consistency is the worked example. Reading a message's roleplay
markup is shared — analysis/text/markup.py classifies the dialogue and
narration axes for markup repair, for TTS speech selection, and for the image
camera's narration extraction, and analysis/text/roleplay.py and
analysis/text/roleplay_segmentation.py hold the span parser all three read.
Repair is not shared: workflows/format_consistency/normalization.py owns the
baseline window vote, the rewrite rules, the skip policy, and
FormatDriftReport. Nothing outside the workflow imports them, and the toolkit
does not re-export them — a toolkit entry would hand another plug-in this
workflow's repair policy by accident and would import the workflow back into
its own API.
The line to apply to a new workflow: publish through the toolkit the primitives a plug-in needs to act on shared text, and keep in the workflow the decisions only that feature makes.
Frontend¶
| Path | Purpose |
|---|---|
frontend/workflow_api.js |
Public plugin facade |
frontend/workflow_loader.js |
Loads one module per manifest entry |
frontend/state.js |
Workflow registries and UI state |
frontend/chat.js |
Chat facade used by the workflow API |
frontend/chat_stream.js, frontend/chat_workflow.js |
SSE dispatch and workflow presentation/attachment actions |
frontend/workflows/<id>/index.js |
Workflow entry point |
frontend/default_widget.js |
Fallback image, audio, video, or download view |
Frontend workflow code imports /static/workflow_api.js and its own relative
modules. It should not import core frontend modules directly.
Use the facade's responseError(response) for a failed HTTP response and
sseError(data) for a terminal core error event. Both preserve useful messages
from structured payloads. Lazy workflow streams can emit a core error when a
hook raises, so handle it alongside the workflow's own terminal events. See the
shared failure contract.
Declare a workflow¶
A plug-in package declares its workflow as WORKFLOW in its __init__.py: a
Workflow record whose id is the package name, carrying its hook
subscriptions. subscription(...) builds each one and holds the hook to the
signature its slot calls it with.
from ..toolkit import HookType, Workflow, subscription
from . import hooks
WORKFLOW = Workflow(
id="my_workflow",
display_name="My workflow",
tools=[],
config_defaults={},
config_schema=None,
produces_artifacts=False,
subscriptions=[
subscription(HookType.POST_PIPELINE, hooks.post_pipeline, priority=0),
],
)
id is the boundary key used in URLs, JSON, tools, and static module paths.
Tool names must be unique and must agree across ToolSpec.name, the schema,
and tool_choice. A workflow binds each hook type at most once, and only a
produces_artifacts=True workflow may bind REGENERATE, REROLL_GEN, or
EXPORT.
Adding a workflow needs no edit to a host file. When backend.workflows is
imported, it registers every package directly under backend/workflows/ in
package-name order, then calls finalize_registry(), which verifies that an
artifact-producing workflow has both regeneration hooks. Modules beside those
packages are host modules and are never registered. Startup stops with the
error when a package fails to import, lacks a WORKFLOW record, declares an id
other than its package name, or declares an invalid subscription, and when a
directory of Python modules has no __init__.py.
Registration order is the manifest order. The frontend loads workflow modules in that order, so it also orders the workflow rows in the Tools panel and the workflow buttons on a message. Re-registering a workflow keeps its position.
A hook that needs a layer below the toolkit lives in a host adapter, which
binds it to the plug-in's id with subscribe in backend/workflows/__init__.py.
The Prose Rewriter's post hook is bound this way because it runs the local model
runtime.
Workflow tools append after the fixed built-in tool order, in registration order. Re-registering an existing tool replaces its contract without changing its position; removing a tool on workflow replacement removes it through the framework-owned catalog API. The catalog itself is not part of the plug-in API.
Hook types¶
| Hook | Runs | Return shape |
|---|---|---|
PRE_PIPELINE |
During a turn, before the main passes; all hooks in priority order | Async stream of events or pipeline instructions |
POST_PIPELINE |
During a turn, after the main passes; all hooks in priority order | Async stream of events, draft changes, state, or attachments |
ON_DEMAND |
Conversation-scoped trigger route | A JSON object or WorkflowEventStream |
REGENERATE |
Attachment regeneration route | A list of new attachment records |
REROLL_GEN |
Attachment reroll and rehydrate routes | Bytes, or bytes plus consumption metadata |
QUERY |
Global configuration/discovery route | One response object |
UPLOAD |
Character-scoped file upload route | One response object |
EXPORT |
Attachment export route; optional | An ExportedFile, or None when nothing is left to export |
QUERY has no conversation or LLM client. It is for setup and discovery, such
as checking an external server before a conversation exists. UPLOAD receives
one file for one character, also without a conversation or client, and its
query-string parameters as a second argument. The framework checks the card
and a 25 MB cap, and holds no lock while the hook runs: the hook takes the
toolkit lock for any state it rewrites, so slow processing of the file blocks
nothing. The TTS voice clone is the worked example. The message-level
regenerate route reruns the normal turn pipeline; REGENERATE is only for an
attachment.
Enablement¶
The settings row is the source of truth:
| Setting | Meaning |
|---|---|
workflows_globally_enabled |
Master switch |
workflow_enabled |
JSON map of per-workflow overrides; missing means enabled |
Effective state is global_on AND local_on. The backend applies it to pipeline
hooks and hook-firing routes. Config, manifest, query, and attachment-consumption
routes remain available so a disabled workflow can be configured and existing
attachments can still be viewed.
The frontend mirrors the same predicate and hides disabled workflow controls. Existing attachment renderers remain available by design.
Hook contexts¶
Contexts are frozen dataclasses. Their mutable fields are read-only views, with
two deliberate exceptions: turn_scratch and the service objects used by the
framework.
| Context | Provides | Notes |
|---|---|---|
PreCtx |
Conversation, history, current user text, settings, prefix, tool map, client, cache tracker | turn_scratch is shared with PostCtx |
PostCtx |
Conversation, final history, effective user text, Director output, merged tools, prefix, client, cache tracker, resolved Agent execution target (agent_client, agent_model_name) |
May stage a draft, state, or attachment |
OnDemandCtx |
Conversation, history, current user text, settings, client, character | Trigger actions |
RegenCtx |
Conversation, message and attachment ids, pre-anchor history, settings, client, character, phase(label), keep(attachment), emit(event, data) |
Attachment regeneration |
RerollGenCtx |
Conversation, message and attachment ids, settings, client, prior consumption metadata, replay |
Shared by reroll and rehydrate |
QueryCtx |
Settings | No conversation and no client |
UploadCtx |
Settings, character id and card, filename, file bytes | No conversation, client, or lock |
ExportCtx |
Attachment id, the row without its bytes, decoded consumption metadata, stored_bytes() |
Bytes load only when the hook asks for them |
Every context, and every control-event type a hook yields (EV_ENABLE_TOOLS,
EV_SYSTEM_PROMPT, EV_DRAFT_REPLACED, EV_ATTACH_ARTIFACT,
EV_SET_MESSAGE_STATE), is a toolkit export, so a plug-in annotates its hooks
without reaching past the toolkit. Both subscription and subscribe are
typed per hook type: Pyright rejects a hook whose signature does not fit its
slot, such as a post hook that returns instead of yielding. The host's
get_subscription and iter_subscriptions preserve that callable type through
lookup and route gating, so dispatch is checked too.
Annotate pre/post generators with AsyncIterator[PreEvent] or
AsyncIterator[PostEvent], and on-demand event streams with
AsyncIterator[PublicEvent]. These types are toolkit exports, as are their
individual control-event types. Pyright checks required payload keys and the
instructions allowed in each slot; for example, a pre-hook cannot replace a
draft. Public events require event: str and optionally data: str | dict;
workflow-specific JSON remains open. The runtime still validates output from
untyped plug-ins and drops malformed events.
from collections.abc import AsyncIterator
from ..toolkit import EV_SYSTEM_PROMPT, PreCtx, PreEvent
async def pre_pipeline(ctx: PreCtx) -> AsyncIterator[PreEvent]:
yield {"type": EV_SYSTEM_PROMPT, "block": "A workflow instruction."}
Context collections are annotated as read-only mappings and tuples, and
clients and cache trackers use their concrete service types. turn_scratch
remains a mutable dictionary for workflow-owned data. Static contract
regressions in tests/unit/workflows/test_static_contracts.py check valid
declarations and dispatch alongside deliberately invalid examples.
For group work, character identifies the relevant speaker. A
RerollGenCtx with replay=True reproduces stored generation parameters;
replay=False lets a new variant use current workflow settings.
State and locks¶
State is JSON-backed and accessed through the toolkit. Use a matching lock for read-modify-write operations.
| State | Scope | Lock |
|---|---|---|
workflow_state |
Conversation + workflow | workflow_state_lock(cid, wid) |
workflow_message_state |
Message + workflow | Owning conversation lock |
workflow_character_state |
Character + workflow | Conversation lock, then workflow_character_state_lock |
workflow_config |
Workflow | workflow_config_lock() |
| Attachments | Root attachment group | Framework's root lock |
The primary runtime import surface is backend.workflows.toolkit. Hook contexts
carry the LLM clients; the toolkit provides semantic host operations, read-only
database queries, state getters/setters, forced_tool_call, attachment
insertion, and workflow locks. Raw prompting, inference, and tool-catalog
objects are intentionally not exposed to plug-ins. Mutating core database
helpers are also excluded.
A workflow inside a turn¶
The bridge gives every turn one scratch dictionary, client, cache tracker, and schema override map. It then follows this flow:
PRE_PIPELINE hooks
↓
Director → Writer → Editor
↓
retain post-Editor draft
↓
POST_PIPELINE hooks
Prose Rewriter (-20)
Format Consistency (-10)
later text/artifact hooks (0+)
↓
persist assistant message, state, and attachments
↓
SSE done
Pre-hooks can add system blocks, enable tools, or emit public events. Post-hooks
can replace the draft, set message state, stage attachments, or emit public
events. Hooks run in subscription priority order, and equal priorities run in
registration order. The Prose Rewriter is a registered post-hook; its negative
priority puts it before Format Consistency and artifact workflows. Its standard
workflow toggle turns the rewriter on for both automatic runs and the
saved-message rewrite route, and its automatic config gates the post-hook
alone. Its workflow card manages the model through the generic Local ML
routes, which also own the shared llama-server runtime. A hook failure is
isolated so the main reply and other workflows can continue.
With the image workflow's GPU swap option on, every turn holds the GPU from
start to done, and a ComfyUI render waits for it. Image renders run from
on-demand, regenerate and reroll hooks, outside any turn. A post-hook that
awaited a ComfyUI render would wait on its own turn until the render timeout.
Stop is checked before each hook and after its locks are acquired, so no hook
starts once the turn is stopped. The running hook is interrupted: its pending
step is cancelled and its generator closed, so a local rewrite or remote
render is torn down rather than waited for. Events it would have yielded after
the stop, such as an auto-play cue, are dropped. What it had already handed
over stays with the reply: a whole draft_replaced draft, a complete
attach_artifact, and set_message_state. A hook's own draft_update
previews never do. Pre-hooks follow the same rule. A hook needs no cancellation
code of its own beyond letting CancelledError propagate, although its
finally blocks do run.
Use forced_tool_call for a one-shot tool call. Pass the context's prefix,
enabled tools, schema overrides, client, and cache tracker so the call follows
the same prompt and cache rules as the main turn. Its budget is the Agent lane's
configured max_tokens, unchanged; a workflow does not pick its own.
Public hook events pass through to SSE after envelope and turn-ownership
validation at both PRE_PIPELINE and POST_PIPELINE. Names beginning with _
are internal. The turn host protects terminal verdicts (done, error), message
identity (user_message_created), group events (speaking_plan, speaker_start,
speaker_done), authoritative content (token, writer_rewrite), and its pass
and persistence reports (director_start, director_done, step_start,
writer_done, editor_done, decisions, feedback, state,
world_change_proposed, workflow_attachments_rejected). A hook cannot publish
these directly. Invalid events are dropped with a logged reason; that hook and
later hooks continue. A draft_replaced control still asks the bridge to publish
its own writer_rewrite; attachment and message-state controls stay internal.
Four shared events are supported: phase_status requires a string channel
and a string label or state; reasoning requires string pass and delta;
draft_update requires a string draft; warning requires a string headline.
When supplied, warning text fields and phase fields must be strings, and
warning.status must be an integer. Feature-owned JSON extensions remain open,
as do custom event payloads. A cosmetic draft_update never changes the saved
reply; only a completed replacement control does. This turn policy does not
apply to on-demand, regeneration, document, or library event streams, whose
hosts own different contracts.
A useful shared event is phase_status with a channel that
starts with workflow:<id>. On a turn stream its label becomes the status
bar's text for the running step, so keep it a short description of the work
(Rewriting prose…); outside a turn it shows as a separate pill.
Attachments¶
Artifact-producing workflows write through insert_workflow_attachment or
yield an attach_artifact instruction from POST_PIPELINE. An attachment has
a workflow id, filename, MIME type, and exactly one byte source (data or
path). The framework validates it before persistence.
Attachments are arranged as a flat variant group: one root and its siblings.
The active sibling is user-selectable. The cache stores bytes in
workflow_attachments and enforces a configurable byte budget. When space is
needed, older accessed rows are evicted by replacing their bytes with the
[evicted] marker.
The message listing (GET /api/conversations/{cid}/messages) carries each
attachment without its bytes: every other column, plus evicted (1 when the
bytes are the marker). Bytes load from the content routes below, which answer
with an ETag the browser revalidates, support byte ranges, and return 410 for
an evicted row. A frontend widget builds the URL with workflowAttachmentUrl;
audio plays through playAudio segments of { row }, or
{ row, byte_start, byte_end } for one clip packed inside a larger attachment.
Supply a seed and JSON generation metadata when an artifact can be recreated.
That lets the user rehydrate evicted bytes. The same REROLL_GEN hook handles:
- reroll — a new seed and a new sibling using current settings;
- rehydrate — the stored seed and parameters, restoring the same row.
REGENERATE also creates siblings, while activate and delete only change
the variant group. A regenerate hook that makes several variants in one run
saves each through await ctx.keep(attachment) as it lands, rather than
returning them all at the end, so Stop keeps what was already made. An
on-demand hook does the same with insert_workflow_variant, passing the ids it
has saved so far as the group and the last one as shown. Either way, a new
variant becomes the active one only while the previous one from the same run is
still on show, so a user who paged to an earlier variant mid-run stays on it.
set_workflow_consumption_metadata rewrites a saved row's consumption metadata
when something about it, such as a review, is known only after it was saved.
Existing artifacts remain readable when their workflow is disabled.
A workflow whose stored bytes are not the best copy of an artifact can
subscribe EXPORT to serve a download. The hook receives the row without its
bytes and a stored_bytes() loader, so it can fetch the file from where it was
made without reading the stored copy. It returns an ExportedFile with bytes,
MIME type, filename, and an optional note the route sends as
X-Orb-Export-Note. Use the note to say when the file is not the best version
and why. Image generation fetches the original from ComfyUI and converts the
stored copy only when no original is available.
HTTP surface¶
The framework exposes:
GET /api/workflows
GET /api/workflows/{wid}/config
PUT /api/workflows/{wid}/config
POST /api/workflows/{wid}/enabled
POST /api/workflows/{wid}/query
POST /api/characters/{card_id}/workflows/{wid}/upload
POST /api/conversations/{cid}/workflows/{wid}/trigger
POST /api/conversations/{cid}/workflows/stop
POST /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/regenerate
POST /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/reroll-gen
POST /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/rehydrate
POST /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/activate
POST /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/delete
GET /api/conversations/{cid}/messages/{mid}/workflow-attachments/{aid}/in-flight
POST /api/conversations/{cid}/workflow-attachments/access
GET /api/workflow-attachments/{aid}/content
GET /api/workflow-attachments/{aid}/export
User uploads have the same split: GET /api/user-attachments/{aid}/content.
A hook reports a failure the user can act on by raising the toolkit's
WorkflowUserFacingError; its message becomes the response detail, with status
502. Two subclasses narrow the status: WorkflowInputError (400) for input the
user supplied that cannot be used, and WorkflowUnavailableError (503) for
something not set up yet, such as a model that is not downloaded. Any other
exception is logged and answers 500 with a generic message.
in-flight reports {"in_flight": bool} for the attachment's canonical root:
whether a request still holds the group's lock. Regenerate, reroll-gen,
rehydrate, and delete hold that lock for their whole duration and release it
only after their write commits, so false means nothing in flight can still
change the group. It exists for a client whose own connection died mid-render,
which otherwise cannot tell a running render from one the server already failed.
A single false does not prove failure -- a queued request has not reached the
lock yet -- so clients confirm it across consecutive polls.
With Accept: text/event-stream, regenerate streams each ctx.phase(label) as
phase_status and each ctx.keep that saved a row as regenerate_sibling
({attachment_id}), then regenerate_done (the JSON body, whose attachments
include the kept rows) or regenerate_error ({status, detail}). The render
outlives a dropped stream.
ctx.emit(event, data) adds a workflow's own structured event to that stream,
for widget data that does not belong in the phase_status label. The name must
start with <workflow_id>_ and must not be one of the four names above, and
data must be a JSON-serializable dict. An event that breaks these rules is
dropped and logged once, never raised, so a malformed status event cannot abort
the render. The client dispatches each one to the handler registered for it with
registerWorkflowEventHandler, called as (data, null), the same way a turn
stream calls it. Outside a stream, emit does nothing.
Image-generation review reasons travel only in the next revision's phase_status
labels, including ComfyUI queue and rendering updates. They are transient progress:
review reasons and timeline metadata are not persisted on attachments or emitted
as separate review events. On-demand generation and regeneration share this path;
their normal phase cleanup removes the reason on success, Stop, or failure.
Workflow renders run beside the chat, so the chat Stop button leaves them
alone; the button that started a render is its Stop button while it runs.
Regenerate, reroll-gen, rehydrate, and the on-demand trigger run as
per-conversation jobs. Each of those routes takes an optional ?job=<id> the
client picks, and workflows/stop?job=<id> cancels that job alone (without
job, every job in the conversation), answering {stopped, settled} once
they have ended (bounded); the stopped request then answers 409. stopped: 0
means no job ran under that id yet or any more. A job already writing its
sibling or restored bytes is waited for, not cancelled; an on-demand hook
that writes its own attachment may still commit it as it is cancelled, so
after a stop the client refetches and the saved rows decide. An on-demand
stream is stopped by closing it instead, which cancels the hook; the hook
must let its cleanup finish (the stream awaits it). A cancelled ComfyUI
render withdraws its prompt by id; cloud image APIs are synchronous, so a
stopped cloud render only drops the request and may still be billed.
The manifest returns workflow identity and config form metadata. Config is a
full replacement; a workflow's config_normalizer owns its valid shape and is
used on both read and write.
get_workflow_config returns the stored slot when non-empty, otherwise an
independent copy of config_defaults, including nested objects and lists. It
does not merge partial stored configuration with defaults. A workflow that adds
settings over time supplies missing values in its normalizer. Clearing the
stored config with {} restores the defaults.
Frontend integration¶
At boot, the frontend fetches the manifest and imports
/static/workflows/<id>/index.js for each entry. Top-level registration calls
run when the module loads.
The facade in workflow_api.js is the frontend ABI. It is additive-only: new
exports may be added, with a WORKFLOW_API_VERSION bump and a matching entry in
FROZEN_ABI in scripts/check_frontend_layers.py, but existing names and
signatures do not change. Common registration points are:
registerWorkflowInspectorCard(wid, render)
registerWorkflowToolsPanelCard(wid, render)
registerWorkflowMessageButton(wid, render)
registerWorkflowEventHandler(wid, event, handler)
registerAttachmentRenderer(wid, render)
registerWorkflowPipeline({ id: wid, passes })
registerTextEffect({ id, label })
registerClickHandler({ id, claims, onClick })
Inspector cards and pipeline reasoning render at the bottom of the Inspector's Main tab.
Use registerAttachmentRenderer for the workflow's own widgets. It is not
enablement-gated so stored artifacts remain visible. Other workflow-owned cards,
buttons, and event handlers are gated by workflow id.
Buttons and inputs use delegated actions rather than globals or inline event handlers:
The handler receives the element carrying the action and the event. Click is
the default; data-wf-on names other events, space-separated: change,
input, keydown, or dragover dragleave drop for a drop target. The core UI
uses the same mechanism, so a workflow's markup may also name a core action. The
lint step fails on an action name that nothing registers.
Handlers may return a promise; synchronous throws and asynchronous rejections
are logged with the action name.
The facade also provides API helpers, modal and notification helpers, workflow phases, shared audio controls, text effects, message access, group cast data, and conversation repaint/refetch helpers.
A button that starts a long render is that render's Stop button while it
runs. startWorkflowJob({ convId, title }) returns a job: job.url(path)
names it on the request, job.show(button) turns the button over in place,
job.stop() cancels it (startWorkflowJob({ title, controller }) aborts the
controller instead, for a stream), and job.end() turns every copy of the
button back. A renderer that may repaint the button mid-render draws it from
stopButtonState(job, idleTitle). workflowActionJob(msgId, attId) is the
job of the core regenerate, reroll, or restore running on an attachment, for a
workflow that draws its own control for those. stopWorkflowJob(jobId) stops a
live job by id (the data-wf-job a Stop button carries), so a control the
framework did not render can stop a framework-owned job; an ended id is ignored.
An attachment renderer receives { att, buttons, defaultHtml, siblings, msgId,
rootId, job }: the shown attachment, the group's attachments in display order,
the message and group root ids, and the group's running regenerate job (or
null). Treat them as read-only. activateWorkflowVariant(msgId, rootId,
siblingId) shows another variant through the arrow buttons' own path. A widget
that draws its own controls instead of buttons reaches the same operations
through stepWorkflowVariant(msgId, rootId, delta),
regenerateWorkflowAttachment(msgId, attId, button),
rehydrateWorkflowAttachment(msgId, attId, button) (button becomes the
render's Stop button), and deleteWorkflowAttachment(msgId, rootId), which asks
before deleting the shown variant or the whole group.
registerRegenerateSettled(wid, (msgId, rootId) => …) is called when a
regenerate ends, on every outcome, because a failed or stopped run repaints
nothing; a widget holding live run state clears it there.
refreshConversationMessages and regenerate sibling refreshes also work while
a chat reply streams. They merge only workflow attachments into existing
message objects and patch the artifact areas in place; live prose, pending
messages, and their DOM nodes stay intact. Once the reply finishes, normal full
message refreshes resume.
Authoring checklist¶
- Create the package
backend/workflows/<id>/; its name is the workflow id. - Implement hooks with the context and return shapes above.
- Export the
Workflowrecord asWORKFLOWfrom the package's__init__.py, with asubscription(...)for each hook. Discovery registers it. - Use the toolkit and matching locks for state changes.
- If producing artifacts, implement
REGENERATEandREROLL_GEN, and store recovery metadata where rehydrate is useful. - Create
frontend/workflows/<id>/index.jsand import only the facade plus relative modules. - Register only non-reserved SSE events and use
data-wf-actionfor controls. - Add config defaults/schema and normalize the effective config if needed.
Quick lookup¶
| Need | Start here |
|---|---|
| Add a hook | contracts.py, registry.py, and workflow_bridge.py |
| Call a tool | toolkit.forced_tool_call |
| Store workflow state | Toolkit state helpers and the matching lock |
| Produce an attachment | attach_artifact and attachment_cache.py |
| Add a custom stream event | Hook event plus registerWorkflowEventHandler |
| Accept a file for a character | UPLOAD hook; tts/hooks.py:upload |
| Add UI | workflow_api.js registrars and registerAction |
Media ownership across edits¶
Media generation stores its source text in generation metadata. A commit for an older source keeps the artifact but does not activate it; conditional sibling activation also preserves a variant selected during rendering. Speech consumption metadata carries its own text and blocks, and karaoke against changed message text is disabled. The automatic TTS hook snapshots the voice profile before generation, while playback preferences remain live. API event streams stay registered as workflow jobs until they settle, so deletion and restore account for their renders.