Starter workflows (experimental)
Some work has to follow your data. An embedding must cover every new ticket, or search misses it. A fit source must read every new interview, or its suggestions fall behind. A starter workflow is a template for work of this kind.
You create a workflow from a starter workflow on a class of your graph,
with the fields to watch and the workflow’s own settings. After each
published commit, the platform hands the workflow the instances of the
class that the commit added or changed. The workflow reads them at that
commit and does its work. The class and its fields are the workflow’s
trigger, its watch. The result is a workflow with a name on the graph.
PUT /v1/workflows/triggered creates it from the starter workflow, and the
routes under /v1/workflows/named read and change it by its name.
The starter workflows
Section titled “The starter workflows”| Starter workflow | Group | What it does | Settings | Same documents as |
|---|---|---|---|---|
search.embed |
Search | Keeps an embedding of the class in step with the graph, for search by meaning. | model, dim, title |
/v1/embeddings |
ontology.fit |
Ontology | Reads the text fields of the class and files ontology suggestions with quotes. See Suggest ontology changes from text. | context |
/v1/ontology/fit-sources |
workflow |
Developer | Sends the new and changed instances of the class to a workflow of your worker, as messages. Your code writes the facts it owns. See Developer workflows. | none; workflow, owns and batch instead |
|
documents.parse |
Documents | Reads the text layer of each uploaded PDF file into pages, with every line and its box. Watches Document. |
none | /v1/files |
documents.link |
Documents | Connects mentions of the same thing across the documents: each reference links to where that thing is defined or described. Watches Document. |
roles; gate, budget_usd_per_month |
/v1/files |
documents.map |
Documents | Maps the links into your own ontology and proposes each statement as a FactProposal with a confidence, for a review workflow. Watches DocMention. |
mappings; budget_usd_per_month |
|
values.extract |
Values from text | Fills chosen properties of the class from its text, each value with a quote. | properties, context, where, repair; gate, budget_usd_per_month |
|
review |
Review | Decides which facts another workflow proposed are written: at its gate, or by a person through a review item. Watches FactProposal. See Review proposed facts. |
class, always_review; gate |
|
rows |
Documents | Reads each CSV, TSV or XLSX file into one instance per row, each linked to the file with its row as the evidence. Your worker runs it. | the template’s settings | |
sections |
Documents | Reads each Markdown file into its sections, each linked to its parent with its lines as the evidence. Your worker runs it. | the template’s settings |
LBB runs documents.parse, documents.link, documents.map,
values.extract and review, and they write the facts they own. See
From documents to a graph.
rows and sections are templates your worker runs on files. A content
trigger sends each one the uploads of its media types: the catalog names
them in media_types, and their watch is none. Create their workflow
with PUT /v1/workflows/named, as npm run setup in a project of the
template does. PUT /v1/workflows/triggered answers
400 template_runs_in_worker for them, and Try on samples is not offered: npm run try in the project
loads the samples. See Read files by their type.
The document workflows feed each other. documents.parse wakes
documents.link. documents.link writes the mentions that wake
documents.map. documents.map writes the proposals that a review
workflow reads.
values.extract was called infer.llm until October 11, 2026. Requests
that name infer.llm still work. Answers always name values.extract.
A workflow and its route family read and write one stored document. An
embedding declared with PUT /v1/embeddings lists as a workflow of
search.embed, and a search.embed workflow created here lists as an
embedding.
Ontology starters are a different feature: base ontologies (CRM, documents, work) that a graph starts from.
1. Read the catalog
Section titled “1. Read the catalog”curl "$LBB/v1/workflows/starters" -H "Authorization: Bearer $LBB_API_KEY"Each entry has the starter workflow’s id and version, a title and
summary, its group (documents, values, search, ontology,
developer or review), its params (name, kind and whether it is required), the
graph kinds it supports in truth, and available. A starter workflow
that this server cannot run now says why in unavailable_reason, for
example fit_models_unavailable.
Every route on this page is graph-scoped. Add ?graph=<id> for a graph
other than main.
2. Preview a workflow
Section titled “2. Preview a workflow”The preview resolves the watch, counts the instances of the class, and
returns the starter workflow’s own preview in details: the fields and
sample texts of an embedding, or the text that the fit reads. It writes
nothing and calls no model.
curl -X POST "$LBB/v1/workflows/triggered/preview" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "starter": "search.embed", "watch": {"class": "https://littlebigbrain.com/class/ticket", "fields": ["label", "description"]} }'fields takes the paths of an embedding: a property name (description),
label, a prefixed name, a full IRI, or a path with one link
(about/label). Without fields, the workflow picks its own: the label and
the frequent text of the class for an embedding, the long text facts for a
fit source. sample sets how many instances to show, and iris names the
instances to show instead.
For ontology.fit, "propose": true runs the models on the sample and
returns their proposals without filing them. That dry run spends model
budget.
3. Create the workflow
Section titled “3. Create the workflow”curl -X PUT "$LBB/v1/workflows/triggered" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "starter": "search.embed", "watch": {"class": "https://littlebigbrain.com/class/ticket", "fields": ["label", "description"]} }'The answer is the workflow with its status. name defaults to
the class’s local name, here ticket. A name is lower-case letters, digits
and dashes, at most 63 characters.
The workflow first reads every instance of the class (state
backfilling). Then it follows each published commit (state ready).
To change a workflow, send the request again with the same starter and
name. The same request again changes nothing, so a retry is safe.
A fit source is created the same way, with its context:
curl -X PUT "$LBB/v1/workflows/triggered" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "starter": "ontology.fit", "watch": {"class": "https://example.org/class/interview", "fields": ["transcript"]}, "params": {"context": "Interviews with employees about their work processes"} }'Retry a failed reading once
Section titled “Retry a failed reading once”A values.extract workflow reads each input once. When the model’s answer
fails a check, the value is dropped. Set repair to 1 to give the input one
more try:
curl -X PUT "$LBB/v1/workflows/triggered" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "starter": "values.extract", "name": "parts", "watch": {"class": "DocEntry", "fields": ["identifier", "context"]}, "params": {"properties": ["width_mm", "material"], "repair": 1} }'A check fails for one of these reasons:
- the answer has the wrong shape;
- a field is missing;
- the property is unknown;
- a value is outside its property’s type or unit;
- a quote is not in the text.
The retry sends the same input and the failed checks. It never sends a label. A property whose first value passed keeps it. The other properties take the retry’s value. Every kept value then goes through the support check and the gate. A repaired value waits for review like any value.
Both attempts stay visible. A test and a run list the values of both. The
retry’s values name attempt: 2. The call log names attempt: 2 on the
retry’s call. For each property one value stands. A test counts, shows and
saves that value.
A retry that fails leaves the first answer. A spent budget stops the input.
repair costs one more call for each input that fails a check. It is off by
default. repair: 0 is the same as leaving it out. A change of repair makes
a new settings revision. Test the two
revisions on the same inputs before you turn it on. You can also compare them
in an experiment. Roll every node of your cell first: an earlier node refuses
repair.
4. Follow the status
Section titled “4. Follow the status”curl "$LBB/v1/workflows/named" -H "Authorization: Bearer $LBB_API_KEY"curl "$LBB/v1/workflows/named?name=ticket" -H "Authorization: Bearer $LBB_API_KEY"The list holds the graph’s workflows by name, at most 100 on a page, in
the list envelope: the workflows are in data,
and next_cursor is the cursor of the next page while has_more is
true. Each workflow names its starter and reports the same status:
| Field | Meaning |
|---|---|
state |
backfilling (reading the instances the class held when the workflow was created), ready (following the published commits), paused, failed (stopped on an error that needs a change), or unavailable (this server cannot run it now). |
processed_through_seq |
The last commit the workflow read. |
published_seq, lag_commits |
The latest published commit, and the published commits the workflow has not read yet. |
backfill |
How far the first read got: target_seq and scanned. |
pending |
Instances waiting to be read. |
pending_since_ms |
When the instances that wait started to wait, in milliseconds since the Unix epoch. Absent while nothing waits. |
counters |
The workflow’s work counters, by name. An embedding counts entries, embedded, reused and removed. A fit source counts the instances read and the proposals kept and dropped. |
spend |
This month’s model spend, in micro US dollars. |
outputs |
The review items the workflow filed, per status. |
last_error, last_error_at |
The last error and its time. |
running |
The step the workflow runs now, absent when none runs: phase in plain words (reading pages, linking mentions), done and total in a unit (pages, mentions, proposals, …), started_at and heartbeat_at. |
To follow a long step, read the workflow every few seconds and show
running.phase with running.done of running.total. The step also shows
as a workflow item in GET /v1/graph/activity.
search.embed and ontology.fit keep their own status too: the
embedding at GET /v1/embeddings?name=ticket, and the fit source at
GET /v1/ontology/fit-sources?name=<name>. The documents of
documents.link and documents.map page through
GET /v1/workflows/named/documents.
Every workflow keeps a summary of each run for 30 days. Use the runs to
see what a workflow did, how long it took and what it cost. A run of a
developer workflow is one turn of your worker, each attempt on its own. A
run of a workflow LBB runs (documents.parse, documents.link,
documents.map, values.extract, review) is one step that did work or
failed.
curl "$LBB/v1/workflows/named/runs?name=record&limit=20" \ -H "Authorization: Bearer $LBB_API_KEY"A run has its origin (what sent its work: a backfill, a range of
commits with their request ids, a run now), its outcome (done, more,
failed, retried, budget, cancelled, paused), the error of a
failed run, what waited before and after (queue_before, queue_after),
what it finished (units in a unit), and a sample of up to 20 of what
it worked on: document names for the document workflows. It also has its
steps, its phases with their times, its counters, and the usage of
its model calls (calls, errors, tokens, cost). The list is newest first,
in the list envelope. Pass next_cursor as cursor for the next page,
and outcome=failed to see the failed runs.
One run comes back with its model calls, each with its model, its usage, its error and the instance it worked on:
curl "$LBB/v1/workflows/named/runs/get?name=record&run=<run id>" \ -H "Authorization: Bearer $LBB_API_KEY"The runs by hour show throughput and backlog: runs, units, calls, tokens,
cost, failed runs, the largest and the last queue, and the time the runs
took, for each of the last hours hours (24 by default, at most 168):
curl "$LBB/v1/workflows/named/runs/stats?name=record&hours=48" \ -H "Authorization: Bearer $LBB_API_KEY"search.embed and ontology.fit run in jobs of their own and keep no
runs: GET /v1/graph/activity lists those jobs.
5. Run now
Section titled “5. Run now”curl -X POST "$LBB/v1/workflows/named/run?name=ticket" \ -H "Authorization: Bearer $LBB_API_KEY"The workflow also runs by itself after each published commit. Run now
asks it to run at once and answers with queued. Read the status again
for the progress.
6. Delete the workflow
Section titled “6. Delete the workflow”curl -X DELETE "$LBB/v1/workflows/named?name=ticket&confirm=ticket" \ -H "Authorization: Bearer $LBB_API_KEY"confirm repeats the name. Deleting a workflow of search.embed deletes
the embedding and its vectors, so the class is no longer searchable. The
suggestions that an ontology.fit workflow filed stay in the inbox.
Developer workflows
Section titled “Developer workflows”Some work needs your own code: a model you run, a parser, a rule of your
business. A developer workflow is a workflow created from the starter
workflow workflow. It watches a class like the other workflows. It sends
the new and changed instances to a workflow of your
worker, in batches, as trigger messages. Your code reads the instances,
does its work, and writes the facts it owns. Those facts can be the watch
of the next workflow. The workflow templates
are developer workflows that do the work of documents.link, values.extract
and ontology.fit in your own code.
A developer workflow needs the workflow engine. Start the server with
--workflows. Without it, the catalog marks workflow unavailable
(workflows_disabled), and a request to create one answers
503 workflows_disabled.
Create a developer workflow
Section titled “Create a developer workflow”curl -X PUT "$LBB/v1/workflows/triggered" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "starter": "workflow", "name": "word-count", "watch": {"class": "Note", "fields": ["body"]}, "workflow": {"workflow_type": "word-count", "version": "v1"}, "owns": {"properties": ["word_count"]}, "batch": 16 }'| Field | Meaning |
|---|---|
watch.class |
The watched class: an IRI, a prefixed name or a local name. Its subclasses count. |
watch.fields |
The fields whose change marks an instance. Empty or absent: a change of any fact of the instance marks it. |
workflow |
Required. The workflow_type and version of your worker that get the messages. |
owns |
What your code writes: classes it creates instances of, relations it writes from the watched instances, properties it writes on them. At most 64 names per list. |
batch |
Instances per message, 1 to 64. Default 16. |
name |
Lower-case letters, digits and dashes. Default: the class’s local name. |
Creating it creates the workflow instance starter.<name> with your type
and version. The workflow then reads every instance of the class (reason
backfill) and follows each published commit (reason changed). Your own
writes to the owned relations and properties never wake it.
A preview of a developer workflow checks the request as a create does and
shows the first messages the workflow would send, in details.messages.
The trigger message
Section titled “The trigger message”{ "type": "instances", "workflow": "word-count", "graph": "main", "as_of_commit_seq": 412, "reason": "changed", "instances": ["https://littlebigbrain.com/e/41ab…", "https://littlebigbrain.com/e/7d02…"], "removed": []}| Field | Meaning |
|---|---|
workflow |
The name of the workflow that sent the message |
reason |
backfill (the pass after a create or a changed watch), changed, refresh (after Run now), or schema_changed (the class’s properties or relations changed) |
as_of_commit_seq |
The published commit the workflow read through. Read the instances at this commit or later. |
instances |
The instance IRIs to work on, at most batch |
removed |
Instances that left the class: drop what you wrote for them |
origin |
Where the message came from: a backfill, a commit range with the watched fields that changed and the requests that changed the instances, a manual run, or a schema_changed pass. It names its trigger. |
Message ids are starter-<name>-<n>. The same id always carries the same
message, so a send that runs again is admitted once. A workflow inbox holds
128 messages. When it is full, the workflow waits and tries
again.
Handle the messages
Section titled “Handle the messages”The TypeScript SDK has two helpers. triggeredWorkflow() defines the
workflow of your worker: it checks each trigger message and calls
onInstances with the typed message and the workflow’s state.
writeOwned() commits what your code owns, with replace_owned, inside a
named step. The step’s effect id is the commit’s idempotency key, so a turn
that runs again after a crash does not write twice.
import { LbbClient, triggeredWorkflow, writeOwned, WorkflowWorker } from "@littlebigbrain/client";
const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY! });
const wordCount = triggeredWorkflow({ name: "word-count", version: "v1", async onInstances(ctx, message) { // Read and count in a step: a turn that runs again reuses the counts. const counts = await ctx.step("count", async () => { const values = message.instances.map((iri) => `<${iri}>`).join(" "); const { rows } = await lbb.withScope({ graph: message.graph }).sparqlRows({ query: `SELECT ?s ?type ?name ?body WHERE { VALUES ?s { ${values} } ?s a ?type ; <http://www.w3.org/2000/01/rdf-schema#label> ?name . OPTIONAL { ?s <https://littlebigbrain.com/p/body> ?body } }`, as_of_commit_seq: message.as_of_commit_seq, }); return rows.map((row) => ({ type: row.type.split("/").pop()!, name: row.name, words: (row.body ?? "").split(/\s+/).filter(Boolean).length, })); }); await writeOwned( lbb, ctx, "write", { subjects: counts.map(({ type, name }) => ({ type, name })), properties: ["word_count"], entity_properties: counts.map(({ type, name, words }) => ({ type, name, properties: { word_count: words }, })), }, { graph: message.graph }, ); },});
await new WorkflowWorker(lbb, [wordCount], { worker: "word-count-1" }).run({ signal: new AbortController().signal,});nameandversionintriggeredWorkflow()are theworkflow_typeandversionof the request that created the developer workflow.message.workflowis the workflow’s name.- A commit names a record by its type and name, or its type and
key. A message names instances by IRI, so read the type (rdf:type) and the name (rdfs:label) with the instance’s facts. The RDF view does not carry a record’skey. - In the same commit,
replace_ownedremoves the owned fields and links of the subjects that the commit does not write again. A changed instance therefore replaces its old output. Name the instances inremovedas subjects without new values to clear their output. onInstancesmay return{ state }to change the workflow’s state; with nothing, the state stays. A message that is not a trigger message fails its turn without a retry.- Creating the developer workflow creates the instance with the state
{}. Each turn handsonInstancesyourinitialStatemerged under the saved state: a saved key wins, and an empty saved state givesinitialState. WithinitialState: { counted: 0 }, the first turn reads{ counted: 0 }. database/examples/starter-workflows/word-count.mjsis a runnable version, and theREADME.mdnext to it has the commands to try it against a local server.
From Python or any other language, send replace_owned in the body of
POST /v1/graph/commit and use a stable idempotency key per message and
step. See Owned outputs.
Review before writing
Section titled “Review before writing”An output below your confidence bar can wait for a person. File it as a
suggestion with facts and origin {kind: "agent", id: "starter:<name>"}.
Accepting commits the facts; dismissing writes nothing. The workflow’s
status counts these items in outputs. See
Ontology suggestions.
Review proposed facts
Section titled “Review proposed facts”A workflow can also propose facts instead of writing them, and let the
review workflow decide. Write one FactProposal per statement: its
subject, predicate and object, your confidence and your evidence. Key it
with proposal:<hash> of the statement and your workflow’s name, so the
same proposal again changes nothing.
curl -X PUT "$LBB/v1/workflows/triggered" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"starter": "review", "gate": {"below": 0.8}}'| Property | What it holds |
|---|---|
fact_subject |
The subject: an instance IRI. |
fact_predicate |
A relation or property IRI of your ontology, or rdf:type. |
fact_object |
An instance IRI, a class IRI for rdf:type, or a literal. |
fact_datatype |
The datatype IRI of a literal. |
fact_confidence |
Your confidence, 0 to 1. |
fact_evidence |
What you saw, as JSON text. |
proposed_by |
Your workflow: workflow/<name>. |
For each new proposal, review sets fact_status:
written: the confidence is at or above the gate. The statement is written, and an edge names the proposal as its evidence.in_review: the confidence is below the gate, a rule ofalways_reviewnames it, or the value would replace another one. By defaultalways_reviewis empty: the gate also decides a new instance. Setalways_reviewto["new_instance"]to send every statement about a new instance to a person. Until 2026-10-12 that was the default. Areviewworkflow created withoutalways_reviewnow uses the new default.refused: the graph cannot hold the statement, for example an unknown predicate or a predicate another workflow owns.review_notesays why.
A person gets one review item per instance, not one per statement. The item of a new part is “New Part P-114 Air filter”. It lists the part’s facts: its class, its label, its identifier and its links, each with its confidence and its line in the document. The link from the document entry is the part’s source. A link to another new instance says “waits for” that instance.
- Accept the item, or uncheck facts and accept the rest. The class of a new instance and its source have no checkbox: to reject the instance, dismiss the item.
- The accept wakes
review, which then writes the checked facts. Until it has written them, the item reads “Accepted, writing”. Unchecked facts turndismissed. - A link to another new instance is written after the item of that instance is accepted. When a person dismisses that item, the link is dismissed too.
- When the proposer changes the proposals of an instance, its open item is
replaced by a new one. An accept of the old item fails with
409and names the new item.
Withdraw a proposal by removing it. review then retracts the fact it
wrote for it, unless a person accepted it. A fact that was in the graph
before is never retracted. review owns the statements it wrote, not
their predicates, so it conflicts with no other workflow. It calls no
model. On an RDF-native graph it writes each statement as one triple of
the graph’s own vocabulary.
Pause and resume
Section titled “Pause and resume”curl -X POST "$LBB/v1/workflows/named/pause?name=word-count" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"paused": true}'A paused workflow reads and sends nothing, and its state is paused.
Its workflow instances take no new turns either. {"paused": false}
resumes both, and the workflow catches up from the commit it read through.
Developer workflows, the workflows of rows and sections, and the
LBB-run workflows (documents.parse, documents.link, documents.map,
values.extract and review) pause and resume. search.embed and
ontology.fit run in their own jobs and answer
400 workflow_pause_unsupported.
Cycles and conflicts
Section titled “Cycles and conflicts”Workflows can feed each other: one owns a class or a relation, and the next
one watches it. A request that makes a loop of two or more workflows is
refused with 409 workflow_cycle, and error.details.cycle names it, for
example ["parts", "orders", "parts"]. Two workflows that own the same
relation or property on overlapping classes answer
409 workflow_owns_conflict. These checks cover the graph’s workflows only.
Another writer that uses the same relation on the same instances is your
responsibility. The facts review writes never conflict: it owns its
statements, not their predicates.
Two workflows with content triggers conflict only when one upload can reach
both. A CSV workflow (text/csv) and a Markdown workflow
(text/markdown) can both own REPRESENTS on one graph. A third one for
text/* or */* that owns it too is refused, and
error.details.media_type names an upload type that both receive.
Status of a developer workflow
Section titled “Status of a developer workflow”status.pending counts the instances waiting to be sent. counters holds
sent_messages, sent_instances, sent_removed, passes, skipped (a
changed instance past 5,000 waiting), inbox_full, id_conflicts, and
inbox_pending: the messages your workflow holds and has not completed.
details adds the workflow instance_id, the workflow, the batch,
paused, the next_message number, the message in_flight, the waiting
pending_instances and pending_removed, and inbox_full_since.
A workflow can read ready while pending is above 0: it follows the
changes and has work queued. Wait for pending == 0 before you read the
results, and for counters.inbox_pending == 0 when your worker must have
handled every message.
The status reads the workflow instance too. When a turn fails for good (a
WorkflowError in the SDK, or the last attempt), the instance stops and
the developer workflow reads failed. last_error names the instance, the
turn, its message and the error. Fix the worker, then retry or skip the
turn with POST /v1/workflows/instances/control. A paused instance keeps
the developer workflow’s state and names itself in last_error.
A delete keeps the workflow instance, its history and the facts your code
wrote. outputs=remove answers 400 workflow_outputs_remove_unsupported;
remove owned facts with a commit that carries replace_owned.
Workflows with triggers
Section titled “Workflows with triggers”A workflow under /v1/workflows/named has a name, a target workflow type
and one trigger. The trigger is a watch on a class, a content trigger on
the files you upload, a schedule, or a stream of events you send.
A workflow of your worker can also spread its messages over several
workflow instances, its shards. Create it with PUT:
curl -X PUT "$LBB/v1/workflows/named" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "name": "word-count", "workflow": {"workflow_type": "word-count", "version": "v1"}, "shards": 4, "route_by": "author", "window": 4, "owns": {"properties": ["word_count"]}, "triggers": [{"id": "notes", "watch": {"class": "Note", "fields": ["body"]}}] }'| Field | Meaning |
|---|---|
shards |
Workflow instances that get the messages, 1 to 16. Shard 0 is starter.<name>, shard k is starter.<name>.<k>. |
route_by |
A field of the class. Records with the same value go to one shard, in order. Without it, each record is its own key. |
window |
Messages queued per shard, 1 to 128 (default 4). The rest waits until a turn finishes. |
triggers |
One watch, with an id that each message’s origin names. |
To change shards or route_by, pause the workflow and let its inboxes
drain first. Otherwise the server answers 409 workflow_shards_busy.
GET /v1/workflows/named/queue?name=word-count lists what the watch has
not sent yet, 50 records a page in the list envelope. Each record shows its
shard and its origin. Each shard shows its messages in flight and in its
inbox. POST /v1/workflows/named/run runs the workflow now, and POST /v1/workflows/named/pause pauses it. LBB’s own workflows list here too,
with an lbb.* target type.
Start on each upload
Section titled “Start on each upload”A watch on Document starts after the upload’s commit is published. A
content trigger starts within seconds of the upload. Each upload to
POST /v1/files adds one entry to the graph’s content log, and the
trigger reads the log:
curl -X PUT "$LBB/v1/workflows/named" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "name": "pdfs", "workflow": {"workflow_type": "pdf-parse", "version": "v1"}, "shards": 4, "triggers": [{"id": "pdfs", "content": {"media_types": ["application/pdf"]}}] }'media_types takes type/subtype, type/* or */*. Each message holds
one file: its Document in instances, and an origin of kind content
with the file’s sha256, media_type, uploaded_at_ms and the upload’s
request id. Messages of one file go to one shard.
A file can reach your worker twice: the same bytes under a second name, or a run now, which reads the log again from its start. Keep a derivation per file. Check it before the work, record it after:
curl -X POST "$LBB/v1/workflows/derivations/check" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"sha256": "3fa2…", "workflow_type": "pdf-parse", "version": "v1", "settings_digest": "none"}'exists: true means this version made something of the file already.
POST /v1/workflows/derivations/record takes the same key with run_id
and outputs (at most 16 KiB of JSON) and keeps the first record of each
key. A new version or new settings (settings_digest) is a new key. In
TypeScript, client.workflows.derivations.derive(key, work) does the
check, the work and the record.
The check also names the upload of the live file, content_uploaded_at.
Send it back with the record. A deleted file has no upload in the check. A file can be deleted while your run works
on it, and uploaded again. Then the record writes nothing and answers
409 derivation_upload_changed. End that run without an error: the run of
the new upload does the work. derive sends the upload for you and
answers uploadChanged: true.
Run on a schedule
Section titled “Run on a schedule”A digest or a nightly check runs on time. A schedule trigger sends one message per slot:
curl -X PUT "$LBB/v1/workflows/named" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "name": "nightly-digest", "workflow": {"workflow_type": "digest", "version": "v1"}, "triggers": [{"id": "nightly", "schedule": {"cron": "0 3 * * 1-5", "timezone": "Europe/Berlin"}}] }'| Field | Meaning |
|---|---|
cron |
Five fields: minute, hour, day of month, month, day of week. @daily and the other macros work too. |
timezone |
An IANA name for cron. Default UTC. |
every |
In place of cron: a period such as 15m, 6h or 1d, in UTC. |
offset |
With every: moves each slot. Every 1d with offset 3h runs at 03:00 UTC. |
jitter |
Each slot runs up to this much later. The delay is the same each time. At most 1 hour. |
Slots come at least 5 minutes apart. A schedule has one shard. Each message names its slot:
{"type": "schedule", "trigger": "nightly", "due_at": "2026-10-22T01:00:00Z", "previous_due_at": "2026-10-21T01:00:00Z", "missed": 0, "as_of_commit_seq": 1842, "since_commit_seq": 1790, "changed": true}Read the graph at as_of_commit_seq. The commits after since_commit_seq
are the changes since the last run. changed is false when nothing
changed. A slot runs anyway, so a run that only reads changes can return
at once.
Each slot runs once. After a downtime, one run takes the newest slot, and
missed counts the others. A slot is skipped while the last run is
unfinished, while the workflow is paused, or when its budget is spent.
POST /v1/workflows/named/run sends one extra run now.
In TypeScript, onSchedule handles the slots:
import { triggeredWorkflow } from "@littlebigbrain/client";
export const digest = triggeredWorkflow({ name: "digest", version: "v1", async onSchedule(ctx, message, state) { if (!message.changed) return; // Read the changes after message.since_commit_seq, at message.as_of_commit_seq. return { state: { ...state, last: message.due_at } }; },});In Python, pass on_schedule= to triggered_workflow. In the local stack,
npm run dev:stack -- --clock moves the time, so a slot runs in seconds.
Run on events
Section titled “Run on events”Orders, webhooks and readings come from outside the graph. A stream trigger runs the events you send, in batches:
curl -X PUT "$LBB/v1/workflows/named" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{ "name": "orders", "workflow": {"workflow_type": "orders", "version": "v1"}, "shards": 4, "batch": 100, "triggers": [{"id": "orders", "stream": {"max_wait": "10s"}}] }'A batch goes when it holds batch events, or when its first event waited
max_wait (1s to 15m, default 10s). The shards stay as you made them.
Send events:
curl -X POST "$LBB/v1/workflows/named/events?name=orders" \ -H "Authorization: Bearer $LBB_API_KEY" -H 'Content-Type: application/json' \ --data '{"events": [{"id": "evt_8f2c", "key": "customer-42", "type": "order.paid", "data": {"order": "A-1007"}}]}'Each event’s id is its idempotency key. The stream drops an id it
received in the last 24 hours, at most 100,000 ids, so a failed send is
sent again with the same events. If you can send an id again later than
that, make your work idempotent. The events of one key go to one shard,
in the order they came. A request takes 1 to 500 events. Each server node
takes 100 events a second for a workflow. Past that, or past 10,000 events
not yet sent, the answer is 429 with Retry-After.
In TypeScript, onEvents gets the events of each batch:
import { triggeredWorkflow } from "@littlebigbrain/client";
export const orders = triggeredWorkflow({ name: "orders", version: "v1", async onEvents(ctx, events, message, state) { for (const event of events) { // event.id, event.key, event.type, event.data, event.received_at } return { state }; },});lbb.workflows.named.sendEvents("orders", events) sends events. In
Python, pass on_events= to triggered_workflow, and send with
client.workflows.named.send_events("orders", events). The Settings tab of the workflow
shows the events waiting, received and sent.
TypeScript
Section titled “TypeScript”import { LbbClient } from "@littlebigbrain/client";
const lbb = new LbbClient({ baseUrl: process.env.LBB_URL!, apiKey: process.env.LBB_API_KEY!, graph: "support",});const TICKET = "https://littlebigbrain.com/class/ticket";
const catalog = await lbb.workflows.starters.list();console.log(catalog.starters.map((entry) => `${entry.id}@${entry.version} (${entry.group})`));const preview = await lbb.workflows.triggered.preview({ starter: "search.embed", watch: { class: TICKET, fields: ["label", "description"] },});console.log(preview.instance_count);
await lbb.workflows.starters.use({ starter: "search.embed", watch: { class: TICKET, fields: ["label", "description"] },});const ticket = await lbb.workflows.named.get("ticket");console.log(ticket.status.state);for await (const workflow of lbb.workflows.named.all()) { console.log(workflow.name, workflow.starter, workflow.status.state);}
await lbb.workflows.named.run("ticket");await lbb.workflows.named.delete("ticket");lbb.workflows.named.runs(name, { cursor, limit, outcome }),
getRun(name, runId), runStats(name, { hours }), queue(name, { cursor, limit }) and documents(name, { q, sort, cursor, limit }) read a
workflow’s runs, its queue and the documents of a document workflow. Each
list is one page in the list envelope; allRuns(name) and
allDocuments(name) iterate every item.
lbb.graph("other").workflows scopes the same methods to another graph.
The ontology starters are lbb.ontology.starters.
lbb.workflows.named.pause(name, paused) pauses (true, the default) or
resumes (false) a workflow. starters.use and pause retry a failed
call, and so does preview without propose.
Python
Section titled “Python”import osfrom lbb import LbbClient
lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph="support")TICKET = "https://littlebigbrain.com/class/ticket"
catalog = lbb.workflow_starters()print([entry["id"] for entry in catalog["starters"]])preview = lbb.preview_triggered_workflow("search.embed", class_=TICKET, fields=["label", "description"])print(preview["instance_count"])
lbb.use_workflow_starter("search.embed", class_=TICKET, fields=["label", "description"])ticket = lbb.workflows.named.get("ticket")print(ticket.status.state)for workflow in lbb.workflows.named.all(): print(workflow.name, workflow.starter, workflow.status.state)
lbb.workflows.named.run("ticket")lbb.workflows.named.delete("ticket", confirm="ticket")class_ and fields form the wire field watch, and params takes the
starter workflow’s settings as a dict (params={"context": "..."} for a
fit source). use_workflow_starter and preview_triggered_workflow
return the JSON answers as dicts; lbb.workflows.named returns the
generated models. AsyncLbbClient has the same methods as awaitables, and
lbb.graph("other") scopes the starter methods to another graph. A
developer workflow is created with workflow=, owns= and batch=, and
lbb.workflows.named.pause("word-count", paused=False) resumes one.
lbb.workflows.named.runs(name, cursor=, limit=, outcome=),
get_run(name, run), run_stats(name, hours=), queue(name, cursor=, limit=) and documents(name, q=, sort=, cursor=, limit=) read a
workflow’s runs, its queue and the documents of a document workflow, one
page at a time; all_runs(name) and all_documents(name) iterate every
item.
Agents
Section titled “Agents”The MCP server has three tools. lbb_workflows is read only: starters
lists the starter workflows this server offers, list the
graph’s triggered workflows with their state, lag, counters, spend and last
error, get one with its own status in details, and preview shows what
creating one would do. workflows lists the workflows with triggers (or
one by name), and queue shows what a workflow’s watch has not sent yet.
lbb_workflows_manage creates a workflow from a starter workflow or
changes one (use), asks it to run now (refresh), pauses and resumes a
workflow (pause, resume; every workflow but search.embed and
ontology.fit), creates or changes a workflow with triggers of your worker
(define), and runs one now (run). use takes workflow, owns and
batch for the starter workflow workflow.
lbb_workflows_delete deletes one, and its confirm must repeat the name.
lbb_files reads the files the document workflows read, with the lines of
each parsed page.
Console
Section titled “Console”Operate → Workflows lists the graph’s workflows in groups: Documents, Values from text, Review, Search and Ontology; then Integrations, Model checks and Search tuning; then Your workflows (developer workflows and message workflows); then Activity graphs. A workflow created from a starter workflow shows its name, the starter id, its trigger (for example “watches DocEntry · identifier, context”), its state, its lag, this month’s cost and its last activity.
New workflow has two tabs. Starter workflows shows a card per
starter workflow: its title, its summary, where it runs and the settings it
takes. Use template opens New workflow from <title>: choose the
class, the fields, the settings and the name, check Preview, then
Create workflow, which opens the new workflow’s page. Fit from text
opens its own dialog, with the text fields and a dry run. The cards of
rows and sections name the media types they read; their Use
template shows the template’s files and the command for a new project,
whose npm run setup creates the workflow. Blank
workflow holds the forms for a message workflow and an activity graph.
A workflow’s page has the tabs Overview, Results, Review, Quality, Spend,
Runs and Settings. See The Workflows page.
Results shows the workflow’s own work, such as the parse per file, the
links per document and the values filled. The page has Run now and
Delete workflow: type the name to confirm, and what the workflow wrote
stays. The page of a developer workflow, documents.parse,
documents.link, documents.map or values.extract adds Pause or
Resume.
Sources → Files has Upload files and lists the files, each parsed file with a page view of its mentions and entries. When the graph lacks a document workflow, the page offers Create Parse documents workflow or Create Link documents workflow.
Errors
Section titled “Errors”LBB-run workflows and workflow activities share a monthly model budget per environment, across all its graphs. Open Environments → Model budget to see the current month’s spend, limit and remaining budget. An owner or admin can change the monthly USD amount, set 0 for unlimited spending, or choose Use default. Members can view it. Changes apply on the next model call without a restart and keep recorded spend intact; other environments keep their own settings. Usage resets on the first day of each month (UTC). Calls already in flight can take spending past the limit.
The SaaS API exposes GET and PUT /stacks/{slug}/workflow-budget. A write
requires the cookie session and CSRF token, with
{monthly_budget_usd: 100, expected_version: 0} (use the version from the
read). null restores the deployment default. A stale version returns 412;
refresh before saving again.
For self-hosted deployments, LBB_STARTERS_MONTHLY_BUDGET_USD supplies the
fallback for environments without an override (default 50 US dollars;
0 = unlimited). Configure the same fallback on serving and maintenance.
A workflow’s own budget_usd_per_month can stop it earlier.
| Error | When |
|---|---|
404 workflow_not_found |
The catalog has no starter workflow with this id. |
404 triggered_workflow_not_found |
The graph has no workflow of this starter workflow with this name. |
404 workflow_run_not_found |
The workflow has no run with this id. |
400 workflow_watch_required |
The starter workflow needs a class to watch, and the request names none. |
400 workflow_param_invalid |
A setting is unknown, has the wrong kind, or is missing. |
400 rdf_native_graph |
The starter workflow does not support the graph’s kind (see truth in the catalog). |
400 workflow_pause_unsupported |
A pause of a workflow of search.embed or ontology.fit. |
400 template_runs_in_worker |
A create or a preview of rows or sections through /v1/workflows/triggered. Create the workflow with PUT /v1/workflows/named. |
400 workflow_outputs_remove_unsupported |
A delete of a developer workflow with outputs=remove. |
409 workflow_cycle |
A developer workflow that makes a loop of workflows. error.details.cycle names it. |
409 workflow_owns_conflict |
A developer workflow that owns a relation or property another one owns on overlapping classes. A review workflow’s facts never conflict. |
429 workflow_budget_exceeded |
A preview with propose: true of documents.link or values.extract when the model budget for workflows is spent. A documents.map workflow stops with it in last_error. |
503 workflows_disabled |
A developer workflow on a server without the workflow engine. |
The errors of embeddings and fit sources pass through unchanged, for example
fit_models_unavailable, fit_budget_exceeded and
embedding_budget_exceeded.