Skip to content

Starter workflows (experimental)

View Markdown

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.

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.

Terminal window
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.

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.

Terminal window
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.

Terminal window
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:

Terminal window
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"}
}'

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:

Terminal window
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.

Terminal window
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.

Terminal window
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:

Terminal window
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):

Terminal window
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.

Terminal window
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.

Terminal window
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.

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.

Terminal window
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.

{
"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.

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,
});
  • name and version in triggeredWorkflow() are the workflow_type and version of the request that created the developer workflow. message.workflow is 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’s key.
  • In the same commit, replace_owned removes 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 in removed as subjects without new values to clear their output.
  • onInstances may 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 hands onInstances your initialState merged under the saved state: a saved key wins, and an empty saved state gives initialState. With initialState: { counted: 0 }, the first turn reads { counted: 0 }.
  • database/examples/starter-workflows/word-count.mjs is a runnable version, and the README.md next 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.

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.

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.

Terminal window
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 of always_review names it, or the value would replace another one. By default always_review is empty: the gate also decides a new instance. Set always_review to ["new_instance"] to send every statement about a new instance to a person. Until 2026-10-12 that was the default. A review workflow created without always_review now uses the new default.
  • refused: the graph cannot hold the statement, for example an unknown predicate or a predicate another workflow owns. review_note says 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 turn dismissed.
  • 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 409 and 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.

Terminal window
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.

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.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.

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:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

A digest or a nightly check runs on time. A schedule trigger sends one message per slot:

Terminal window
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.

Orders, webhooks and readings come from outside the graph. A stream trigger runs the events you send, in batches:

Terminal window
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:

Terminal window
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.

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.

import os
from 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.

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.

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.

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.