# Starter workflows (experimental)

Source: https://docs.littlebigbrain.com/guides/starter-workflows/

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

| 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](https://docs.littlebigbrain.com/guides/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](https://docs.littlebigbrain.com/guides/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](#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](#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](https://docs.littlebigbrain.com/guides/documents-to-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](https://docs.littlebigbrain.com/guides/workflow-templates/#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](https://docs.littlebigbrain.com/guides/ontology-starters/) are a different feature:
base ontologies (CRM, documents, work) that a graph starts from.

## 1. Read the catalog

```sh
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

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.

```sh
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

```sh
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`:

```sh
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

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:

```sh
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](https://docs.littlebigbrain.com/guides/workflow-settings-history/). 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

```sh
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](https://docs.littlebigbrain.com/sdks/http-api/#lists): 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`.

### Runs

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.

```sh
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:

```sh
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):

```sh
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

```sh
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

```sh
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

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](https://docs.littlebigbrain.com/guides/workflows/) 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](https://docs.littlebigbrain.com/guides/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

```sh
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

```json
{
  "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

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.

```ts
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](https://docs.littlebigbrain.com/sdks/http-api/#owned-outputs).

### 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](https://docs.littlebigbrain.com/guides/ontology-suggestions/#facts).

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

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

### Pause and resume

```sh
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

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

`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

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`:

```sh
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

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:

```sh
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:

```sh
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

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

```sh
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:

```json
{"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:

```ts
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

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

```sh
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:

```sh
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:

```ts
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

```ts
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

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

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

**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](https://docs.littlebigbrain.com/guides/workflows/#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

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