911f35c9b8 fix: workflows are charged for cached model answers and stop on a budget they never spent (#134)
* fix(workflows): do not bill cached llm.invoke replays against cost_limit

A cache or run-state hit returns the stored result verbatim, including the
usage of the call that produced it. trackStepCost recorded that usage again,
so every replay was charged as if it had reached a provider. A workflow that
asks the same question in several steps therefore reports a multiple of what
it spent, and cost_limit with action: stop aborts a run that stayed well
inside its budget.

Skip items whose source is "cache" or "run_state". Those are the only two
paths that re-emit a stored result, and no tokens are consumed on either, so
the reported token totals stay truthful too.

* fix(llm): mark replayed results so live calls are never mistaken for them

The cost guard keyed on source being "cache" or "run_state", but source is not
a replay marker: for a direct adapter it defaults to the provider name
(llm_invoke.ts:549), so an embedder registering an adapter under either name
produces live results that the guard would silently drop from _meta.cost and
cost_limit. Any step emitting that source field had the same effect.

Set an explicit replayed flag at the two sites that re-emit a stored item, and
key the guard on it. The flag is added when the item is replayed, not when it
is stored, so entries already on disk are covered.

* fix(workflows): keep billing workflow output that only looks replayed

trackStepCost inspects every JSON result a workflow step produces, not just
LLM results, so keying the exemption on a bare replayed field let any command
that reports its own replay state beside a real usage object drop out of
_meta.cost and slip past cost_limit. Under-reporting spend is the mirror of
the bug this branch fixes.

Gate the exemption on the normalized item llm.invoke and llm_task.invoke
actually re-emit: the replay marker plus cached, a known item kind, and the
string cacheKey, status, createdAt, and source fields the command always
writes. A shell step or an unrelated tool that happens to carry replayed no
longer qualifies. Checking the shape alone would not be enough either, since
a live call through an adapter registered as "cache" carries the same kind.

* fix(workflows): key the replay exemption to in-process provenance

The replay marker was a set of JSON fields, and workflow cost accounting
reads the JSON of every step — including shell steps, whose stdout is
parsed straight into the same shape. A step that printed a replay-shaped
object beside a real `usage` object dropped out of `_meta.cost`, which
under-reports spend and lets a configured `cost_limit` be bypassed.

Mark replays with a symbol key instead. `JSON.parse` cannot produce one,
so only items this process built in `llm.invoke` are exempt; the public
`replayed` field stays for consumers but no longer grants the exemption.

Tests now drive the exemption through the real path (a pipeline step
running `llm.invoke` against a stub provider, for both the cache and
run-state replays) and pin the forged case: a shell step printing every
accepted replay field plus real usage is still billed and still trips
`cost_limit`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6UVfFPP39RkoYx5jZ3KKM

* fix(workflows): keep replay provenance through a rendered pipeline

`llm.invoke | json` is a supported step shape, and it defeated the
symbol-keyed replay exemption. The renderer prints its items and returns
an empty stream, so the step has no pipeline items and its JSON is parsed
back out of stdout — objects that no symbol can survive into. Cached and
run-state replays were billed again there, inflating `_meta.cost` and
stopping workflows below their real provider spend.

Keep the originals instead of trying to recover them. Each stage's
renderer now records the objects it was handed, the pipeline returns them
alongside its items, and a step whose pipeline produced no items carries
them on a non-enumerable symbol key. Cost accounting reads those in
preference to the re-parsed JSON, so a rendered replay is recognized for
what it is.

Nothing about the forged case changes: the side channel holds only items
this process built, so a step that prints the full replay shape — through
a renderer or not — is still billed and still trips `cost_limit`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YJKpXm7WiSbyjQzrFS5Ps

* fix(llm): mark the usage record so a projection keeps replay provenance

`pick model,usage` builds a new object out of named fields, so a replayed
item's own mark never reaches workflow cost accounting and the replay is
billed again — the same defect the renderer had, one command over.

Mark the usage record as well as the item. A projection carries the usage
record across by reference, and the usage record is what gets billed, so
the exemption survives without accounting having to recognize every
command that can build a new object. `where`, `head`, `sort` and `dedupe`
yield the original item and were already covered; `group_by` and `map
--wrap` nest the usage where nothing bills it.

`JSON.parse` still cannot produce the key, so a step that prints a usage
object is billed whether or not it is projected or rendered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YJKpXm7WiSbyjQzrFS5Ps

* fix(llm): bill a replay that stands in for a retried step's live call

A workflow records a step's cost only once the step succeeds, but `llm.invoke`
persists its answer to run state and the cache before it returns. So a step that
fails *after* its LLM call and is retried never bills the live item, and the
retry replays the stored answer -- which the replay exemption then dropped,
reporting $0 for a provider call that really happened.

Provenance now carries the cache key and whether the item was replayed, and a
live call opens an unbilled charge against that key. Accounting claims the
charge, so the live item and every replay of it settle it exactly once: a replay
is exempt only while the call behind it has already been paid for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(llm): scope the unbilled-spend ledger to the run that opened it

The ledger of live calls awaiting a charge was process-global, so a call made
outside a cost-tracked run -- the SDK pipeline API, or a workflow whose step
never succeeded -- left a key behind that a later, unrelated workflow could
claim by replaying the same cache entry. That workflow called no provider, yet
its `_meta.cost` and `cost_limit` grew by someone else's spend.

The ledger is now created per workflow run and reaches commands through the
pipeline context, so only the run that paid for a call can recover its charge
from a replay. A live call with no ledger in context opens no charge at all,
which is what an SDK caller outside cost accounting should do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(llm): count outstanding charges per cache key, not just their presence

Two identical calls that race on a cold cache -- two parallel branches asking
the same question -- are two provider charges under one cache key. The ledger
held a set of keys, so both recorded the same entry and only one of the replays
that later stood in for them could settle it. A retried step therefore reported
one call's spend for two, in `_meta.cost` and against `cost_limit`.

Charges are now counted per key, so N live calls can be settled N times and
never fewer. A replay still settles at most one charge, so nothing is billed
twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(workflows): carry recorded spend across an approval or input resume

A gate pauses a run; it does not reset what the run has spent. The resume is a
separate run with its own accounting, so the provider call made before the gate
lived only in the paused run's record: a later step repeating that prompt
replayed it and correctly billed nothing, leaving the call in no total at all.
The same reset let a `cost_limit` be walked past one gate at a time, on `main`
as much as here.

The paused run now stores its cost summary in the resume state, and the run that
resumes seeds its tracker from it, so `_meta.cost` covers the whole workflow and
the budget survives the pause. Stored entries are rebuilt through the same
normalization as live usage, and a resume state written before this change
simply seeds nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(workflows): keep an unbilled call across a pipeline input resume

A pipeline can pause between paying for a call and billing it: `llm.invoke |
ask` suspends in tool mode after the model has answered, and `ask` consumes the
item that carried the usage, so the step never reports it. The charge existed
only as an outstanding entry in the paused run's ledger, which the resumed run
recreated empty -- so the replay a later step produced was exempt and the call
was billed nowhere.

Outstanding charges now travel in the same resume state as the cost summary and
reopen on resume. It is the same run continuing, so this cannot hand a charge to
an unrelated one, and a resume state written before this change reopens nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(llm): open the charge when the provider answers, not after it is stored

`record()` ran after `persistOutputs` and the cache write. Either can fail --
an unwritable `LOBSTER_CACHE_DIR`, a full disk -- after run state already holds
a replayable copy of the answer, so the step failed, the retry succeeded from
that replay, and the replay had no charge to settle: a completed provider call
was billed nowhere.

The charge exists the moment the provider answers, so it is opened there. A
store that then fails leaves an entry nothing claims, which is bounded and
per-run; the reverse dropped real spend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* docs(readme): state that spend is counted once and survives a pause

The two accounting properties this branch establishes are invisible from the
outside until a bill disagrees with a budget: a replayed answer is not charged
again, and a workflow that pauses at a gate keeps what it has spent, so a cost
limit covers the run rather than the steps after the last pause. The second one
changes what an existing resumed workflow does, which is a thing to read before
it happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(workflows): carry replay provenance across a composed workflow

A `workflow:` step rebuilds its result from the child's serialized
output, so the in-process marker that says which model call an item
replays did not survive the boundary. A child ending in a rendered
pipeline such as `llm.invoke | json` therefore handed back an unmarked
replay, and the composing run billed it again and could stop on a
cost_limit it never spent.

The child now exposes the items it produced on its run result, and its
ledger is chained to the composing run's, so a live call is opened as a
charge in both: each of them counts it once at its own boundary, and a
replay neither of them paid for still settles nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): settle live results before the replays billed with them

A step's parallel branches are accounted in declaration order. A cached
replay declared before an identical --refresh branch claimed the charge
that branch had just opened and was billed for it, and the live branch
was billed whatever its own claim returned -- one provider call charged
twice, enough to stop a run at a budget it had not spent.

Replays are now held back until every live result accounted alongside
them has settled its own charge, so a replay can only draw on a call
nothing else will bill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): bill a call no item ever carried to the accounting point

An item is the usual carrier of a call's usage, but three paths lose it
before anything is billed: a renderer consumes the pipeline and a later
stage emits items of its own, an `ask` gate swallows the only item and
the resume produces no replay, and a composed run ends on a step that
carries nothing back. In each the run had already paid the provider, and
the spend was missing from both `_meta.cost` and `cost_limit`.

Each charge now carries what the call cost, and a run settles whatever
is still open when it completes. A paused run never reaches that point,
so its charges keep travelling in the resume state instead.

The same rule reaches a call made by a step that then failed: nothing
billed its items, so the charge is the only record left that the
provider answered, and the run that made the call is where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): settle a charge from the copy of an item that lost its mark

A stage that JSON round-trips the stream hands on an item that kept
`model` and `usage` and lost everything this process attached to it.
That copy is billed, as it always was, but nothing settled the charge
behind it, so the end-of-run pass billed the same provider call again.

The copy now settles it. The public cache key alone cannot -- any step
can print one -- so the ledger settles a charge only when the cost being
billed is the cost it recorded. An invented item can still add spend to
a run, which was always true, but it cannot hide any.

Comparing only the numbers matters: a live item's usage record carries
this module's provenance symbol, which a copy that went through JSON
never can, so comparing the objects whole never matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): bill each call at its own cost, and before the next step

Two ways the ledger still reported the wrong number.

A replay was billed from the copy it carries. Identical calls that race
on a cold cache are separate charges under one key, only one of their
answers is stored, and every replay repeats that one -- so a retried
step reported both calls at the price of whichever won the cache write.
The charge records what its own call cost, so that is what is billed.

A charge no item carried waited for the end of the run, so `cost_limit`
saw no spend at the step that blew the budget and let every later step
run its side effects first. It is settled as each step finishes instead.

Settling per step means a copy of that item can surface a step later, so
the ledger now remembers what it has already accounted for: such a copy
either settles an open charge and is billed as its carrier, or stands
behind a call already billed and is not billed twice. Both answers need
the cost to be one the run recorded, so an invented item still cannot
hide spend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): enforce the budget a failed step already spent

A step that pays a provider and then fails takes its items with it, so
`on_error: continue` advanced the run with the charge still open and the
limit unchecked. A budget already gone let the next step run its side
effects, which is exactly what `action: stop` exists to prevent.

The charge is settled and the limit checked on that path too, before the
run is allowed to move on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): match a copied item on the tokens that are billed

Comparing every number in a usage record asked a stricter question than
the accounting does. A stage that rebuilds an item can keep the token
counts and drop or recompute a `totalTokens` nobody is charged for, and
the copy then settled nothing: it was billed beside a charge that still
went on to be settled at the end, doubling one provider call.

`CostTracker` now exposes the counts it bills, and the ledger asks it
rather than keeping a second opinion, so the two cannot drift. The
provider spellings of those counts read as one charge for the same
reason: they already bill as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): read a copied item by its cost, and open no empty charge

Two ways one provider call could still be billed twice.

A transform can emit `{ model, usage }` and drop the cache key with the
symbols. That copy took the untouched path, was billed, and left the
charge behind it open for the settlement to bill again. The key was only
ever the index; the cost is the evidence. A copy that still names a key
is read against that key alone, so one call's copy cannot settle
another's, and a copy that named none is read against every open charge.

An answer that reports no usage is billed nowhere however many times it
is replayed, but still opened a charge. The next caller with a real call
to account for was handed that empty charge, leaving its own open to be
billed on top of the item already recorded. No charge is opened for an
answer that costs nothing, and `claim` prefers one that can be billed so
a charge restored without a cost cannot stand in either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): settle a live call against the charge it opened

`claim()` took the first charge under the key, and a step retried after
an attempt that failed *after* paying has more than one. The retry's
item settled the failed attempt's charge and was billed at its own
price, leaving the retry's charge for the step settlement to bill at
that same price -- the retry counted twice and the attempt before it
not at all, at whatever the two really cost.

A caller that knows what its own call cost now says so, and the ledger
settles that call's charge. Replays still take the first billable
charge: a replay is not a witness of what it stands in for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(llm): let a keyless copy add a record of a call, never withhold one

Matching an unmarked item to a charge by cost alone was allowed to
answer "already billed", and that is not an answer cost alone can earn.
Two unrelated objects can carry the same model and the same token
counts, so an ordinary step printing `{ model, usage }` could drop
itself out of `_meta.cost` and `cost_limit` by resembling a call the run
had made earlier.

The two answers are not equally cheap now. Settling an open charge only
ever adds a record of a call, so a copy carrying the cost may do it.
Withholding one removes a record, so that needs the cache key as well: a
copy that names the call it came from *and* costs what that call cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): read a copy only after the marked results have settled

A copy that lost its cache key is read by cost alone, and it was read
while every charge in the step was still open. Two live calls costing
the same under different keys, one branch handing on a filtered copy:
the copy settled the other branch's charge, that branch was billed
regardless, and the copy's own call was left for the step settlement.
Two provider calls, three records.

Copies now wait alongside replays and are read last, when the only
charges still open are the ones no marked result claimed. Replays go
first among the two: a replay carries the mark of the call it stands in
for, which is better evidence than a cost on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(state): carry what a stored value stands in for back out of storage

`llm.invoke | state.set k | state.get k` rebuilds the item from its own
JSON, and the marks this process attached are deliberately not in that
JSON. A replay of a call an earlier run paid for came back looking like
an ordinary record of spend and was billed, so a run that called no
provider could still report cost and trip `cost_limit`.

`state.set` remembers what it wrote, and `state.get` carries the marks
back onto the value it rebuilds -- only when the file still matches
those bytes exactly, so a value this process was never handed cannot
pick up marks it was never given.

What is carried is always a replay, whatever the source was: a value
read back out of storage re-emits an answer that already exists, and
that is not the call happening again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(llm): open the charge for an attempt the validator sends back

The charge was opened at the two returns, and an attempt rejected by
`--output-schema` reaches neither: it goes round the loop and asks the
provider again. That call was as real as the one that eventually
satisfies the schema, and it was in no total and against no budget.

It is opened where the provider answers instead, which is also where it
was always meant to be -- ahead of the writes that store the answer, so
a retry replaying a stored copy still finds a charge to settle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): restore what a paused run billed, not only what it spent

A resumed run gets back the money the paused run spent but no record of
what it was for, so the two halves of its accounting disagree. The
completed steps arrive as JSON, which carries a cache key but none of the
marks this process attached, and a later step that re-emits a completed
`llm.invoke` output hands on a copy the fresh ledger has never seen. It is
billed on top of the restored total: one provider call, twice in
`_meta.cost` and twice against `cost_limit`.

The settled charges now travel in the same resume state as the open ones.
They are read only against the cache key they name, so a restored record
can excuse a copy of the call it names and nothing else; a live call still
settles out of the open charges, and the spend being reconstructed is
already in the `cost` the same state carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* docs(readme): bound the replay exemption at the process edge

The exemption needs a mark this process attached, and a stage that hands
items to an external program and reads its stdout back gets whatever that
program printed. State the boundary rather than leave it to be discovered
by a `cost_limit` that trips on a run which called no provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(workflows): keep a discarded wait:any branch out of the run's total

`wait: "any"` returns the first branch to answer and abandons the rest,
but an abandoned branch is not a stopped one: it can still be waiting on
a provider and it pays when the answer arrives. That charge went straight
to the run ledger, so whether the workflow billed it turned on nothing
but how long the run happened to live afterwards — the same workflow over
the same three calls reported 2000 tokens or 3000, and `cost_limit`
inherited the same coin flip. `main` is deterministic here, so this was
the ledger's own regression rather than a boundary it merely exposed.

A losing branch now opens its charges in a buffer of its own and only the
winner releases them into the run: the output of a discarded branch is
thrown away and its accounting goes with it, which is what `main` already
did. Reads still pass through, because they settle charges opened before
the race and are made by the workflow after a branch returns, never from
inside one. `wait: "all"` keeps every branch, so it bills as before.

Whether losers should instead be drained before the step settles is a
separate question about wait-any latency, and is left open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AVQmKec1UHHXyUhx4BNvS

* fix(workflows): price a live call from the charge it opened

A step's item reaches cost accounting after its pipeline has run, and a
pipeline can drop the model on the way -- `| pick usage` hands on the marked
usage record without it -- or rewrite it. The step was then priced from what
the item said rather than what the provider was asked for: nothing at all in
the first case, another model's rate in the second, with `cost_limit` reading
the difference as room left.

The charge the call opened already carries the real model and usage, and the
deferred replay path is billed from it for the same reason. A charge restored
from resume state written before it carried a cost still falls back to the
item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYzZx7UUfvMLvdtkiMaMs1

* fix(llm): harden replay cost provenance

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-13 12:26:26 -07:00
2026-05-28 19:43:06 +01:00
2026-01-24 12:22:13 -08:00
2026-01-17 18:13:48 -08:00
2026-06-22 14:26:35 +08:00

🦞 Lobster

Lobster banner

An OpenClaw-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.

Example of Lobster at work

OpenClaw (or any other AI agent) can use lobster as a workflow engine and avoid re-planning every step — saving tokens while improving determinism and resumability.

Watching a PR that hasn't had changes

node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1152}'"
[
  {
    "kind": "github.pr.monitor",
    "repo": "openclaw/openclaw",
    "prNumber": 1152,
    "key": "github.pr:openclaw/openclaw#1152",
    "changed": false,
    "summary": {
      "changedFields": [],
      "changes": {}
    },
    "prSnapshot": {
      "author": {
        "id": "MDQ6VXNlcjE0MzY4NTM=",
        "is_bot": false,
        "login": "vignesh07",
        "name": "Vignesh"
      },
      "baseRefName": "main",
      "headRefName": "feat/lobster-plugin",
      "isDraft": false,
      "mergeable": "MERGEABLE",
      "number": 1152,
      "reviewDecision": "",
      "state": "OPEN",
      "title": "feat: Add optional lobster plugin tool (typed workflows, approvals/resume)",
      "updatedAt": "2026-01-18T20:16:56Z",
      "url": "https://github.com/openclaw/openclaw/pull/1152"
    }
  }
]

And a PR that has a state change (in this case an approved PR)

 node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1200}'"
[
  {
    "kind": "github.pr.monitor",
    "repo": "openclaw/openclaw",
    "prNumber": 1200,
    "key": "github.pr:openclaw/openclaw#1200",
    "changed": true,
    "summary": {
      "changedFields": [
        "number",
        "title",
        "url",
        "state",
        "isDraft",
        "mergeable",
        "reviewDecision",
        "updatedAt",
        "baseRefName",
        "headRefName"
      ],
      "changes": {
        "number": {
          "from": null,
          "to": 1200
        },
        "title": {
          "from": null,
          "to": "feat(tui): add syntax highlighting for code blocks"
        },
        "url": {
          "from": null,
          "to": "https://github.com/openclaw/openclaw/pull/1200"
        },
        "state": {
          "from": null,
          "to": "MERGED"
        },
        "isDraft": {
          "from": null,
          "to": false
        },
        "mergeable": {
          "from": null,
          "to": "UNKNOWN"
        },
        "reviewDecision": {
          "from": null,
          "to": ""
        },
        "updatedAt": {
          "from": null,
          "to": "2026-01-19T05:06:09Z"
        },
        "baseRefName": {
          "from": null,
          "to": "main"
        },
        "headRefName": {
          "from": null,
          "to": "feat/tui-syntax-highlighting"
        }
      }
    },
    "prSnapshot": {
      "author": {
        "id": "MDQ6VXNlcjE0MzY4NTM=",
        "is_bot": false,
        "login": "vignesh07",
        "name": "Vignesh"
      },
      "baseRefName": "main",
      "headRefName": "feat/tui-syntax-highlighting",
      "isDraft": false,
      "mergeable": "UNKNOWN",
      "number": 1200,
      "reviewDecision": "",
      "state": "MERGED",
      "title": "feat(tui): add syntax highlighting for code blocks",
      "updatedAt": "2026-01-19T05:06:09Z",
      "url": "https://github.com/openclaw/openclaw/pull/1200"
    }
  }
]

Goals

  • Typed pipelines (objects/arrays), not text pipes.
  • Local-first execution.
  • No new auth surface: Lobster must not own OAuth/tokens.
  • Composable macros that OpenClaw (or any agent) can invoke in one step to save tokens.

Quick start

From this folder:

  • pnpm install
  • pnpm test
  • pnpm lint
  • node ./bin/lobster.js --help
  • node ./bin/lobster.js doctor
  • node ./bin/lobster.js "exec --json --shell 'echo [1,2,3]' | where '0>=0' | json"

Notes

  • pnpm test runs tsc and then executes tests against dist/.
  • bin/lobster.js prefers the compiled entrypoint in dist/ when present.

Commands

  • exec: run OS commands
  • exec --stdin raw|json|jsonl: feed pipeline input into subprocess stdin
  • where, pick, head: data shaping
  • json, table: renderers
  • approve: approval gate (TTY prompt or --emit for OpenClaw integration)

Next steps

  • OpenClaw integration: ship as an optional OpenClaw plugin tool.

Workflow files

Lobster workflow files are meant to read like small scripts:

  • run: or command: for deterministic shell/CLI steps
  • pipeline: for native Lobster stages like llm.invoke
  • approval: for hard workflow gates between steps
  • stdin: $step.stdout or stdin: $step.json to pass data forward
lobster run path/to/workflow.lobster
lobster run --file path/to/workflow.lobster --args-json '{"tag":"family"}'

Example file:

name: jacket-advice
args:
  location:
    default: Phoenix
steps:
  - id: fetch
    run: weather --json ${location}

  - id: confirm
    approval: Want jacket advice from the LLM?
    stdin: $fetch.json

  - id: advice
    pipeline: >
      llm.invoke --prompt "Given this weather data, should I wear a jacket?
      Be concise and return JSON."
    stdin: $fetch.json
    when: $confirm.approved

Notes:

  • run: and command: are equivalent; run: is the preferred spelling for new files.
  • pipeline: shares the same args/env/results model as shell steps, so later steps can still reference $step.stdout or $step.json.
  • If you need a human checkpoint before an LLM call, use a dedicated approval: step in the workflow file rather than approve inside the nested pipeline.
  • cwd, env, stdin, when, and condition work for both shell and pipeline steps.
  • Use retry, timeout_ms, and on_error per step to control transient-failure behavior and recovery.
  • Approval steps can optionally enforce identity constraints:
    • approval.required_approver (or requiredApprover) requires an exact approver id.
    • approval.require_different_approver (or requireDifferentApprover) requires approver id to differ from initiator.
    • approval.initiated_by (or initiatedBy) sets the initiator id for comparison.
    • LOBSTER_APPROVAL_INITIATED_BY can provide a default initiator id at run time.
    • LOBSTER_APPROVAL_APPROVED_BY is used at resume/approval time for identity checks.

Command-level input requests

Pipeline commands can call ctx.requestInput({ prompt, responseSchema, defaults, subject, suspendedState }) to pause in tool mode, workflows, or the SDK and resume the same command after a structured response. CLI/tool resume tokens store only a state key; the persisted state validates the suspended request metadata before returning the submitted response to the command. SDK same-command resumes store the command frame in the configured SDK state directory.

Commands are re-run on resume, so they must be idempotent until requestInput returns. Array-backed command input is snapshotted with bounds for replay; lazy stream input is not buffered and requires a compact JSON suspendedState supplied by the command. On resume, call ctx.requestInput.getSuspendedState() before reading lazy input to restore that command-owned continuation state.

Visualizing workflows

Use lobster graph to inspect workflow structure before execution.

lobster graph --file path/to/workflow.lobster
lobster graph --file path/to/workflow.lobster --format mermaid
lobster graph --file path/to/workflow.lobster --format dot
lobster graph --file path/to/workflow.lobster --format ascii
lobster graph --file path/to/workflow.lobster --args-json '{"location":"Seattle"}'

What gets visualized:

  • each workflow step as a node (run, pipeline, approval, etc.)
  • data-flow edges from stdin: $step.stdout / $step.json references
  • conditional dependencies from when: / condition: expressions
  • approval gates as diamond-shaped nodes in mermaid and dot output

Format notes:

  • mermaid (default): emits flowchart TD text for GitHub/Markdown rendering
  • dot: emits Graphviz DOT syntax
  • ascii: emits a terminal-friendly node/edge list

Calling LLMs from workflows

Use llm.invoke from a native pipeline: step for model-backed work:

llm.invoke --prompt 'Summarize this diff'
llm.invoke --provider openclaw --prompt 'Summarize this diff'
llm.invoke --provider pi --prompt 'Summarize this diff'

Provider resolution order:

  • --provider
  • LOBSTER_LLM_PROVIDER
  • auto-detect from environment

Built-in providers today:

  • openclaw via OPENCLAW_URL / OPENCLAW_TOKEN
  • pi via LOBSTER_PI_LLM_ADAPTER_URL (typically supplied by the Pi extension)
  • http via LOBSTER_LLM_ADAPTER_URL

Workflow _meta.cost and cost_limit use a static pricing table plus optional overrides from LOBSTER_LLM_PRICING_JSON, for example {"my-model":{"input":1.0,"output":2.0}} in USD per million tokens. Unknown or missing model IDs still record token counts with zero estimated cost, but Lobster warns on stderr so stale or missing pricing does not fail silently.

A cached or replayed answer is not billed again: a model call is counted once, in the run that made it, however many later steps re-emit its answer. This holds while the answer stays inside Lobster: through pipelines, renderers, projections, run state, workflow: steps and a resume. It does not survive a stage that hands the items to an external process and reads them back — exec --stdin json --json ... — because what comes back is whatever that process printed, and Lobster cannot tell a faithful copy of a replay from a fresh claim to have made the call. Such a step is billed as a call, which is what earlier versions did everywhere. A workflow: step counts what its sub-workflow spent, and a replay the sub-workflow returned is not billed a second time by the run that composed it. Spend also survives a pause — a workflow that stops at an approval or input gate keeps what it has recorded, so _meta.cost covers the whole run after a resume and cost_limit applies to the whole run rather than to the steps after the last gate.

llm_task.invoke remains available as a backward-compatible alias for the OpenClaw provider.

Calling configured OpenClaw agents

Use openclaw.agent when a workflow needs a configured OpenClaw agent rather than a direct model call:

openclaw.agent --agent ops --prompt 'Summarize these logs'
openclaw.agent --agent ops --session-key incident-42 --model openai/gpt-5.4 --prompt 'Continue the investigation'

The command delegates agent identity, model defaults and overrides, sessions, authentication, and execution to the installed openclaw agent CLI. It accepts --agent, --session-key, --session-id, --model, --thinking, --timeout, and --local, and returns OpenClaw's structured --json response. Pipeline input is appended to the prompt as labeled JSONL.

pipeline: vs run: for LLM calls

  • Use pipeline: for openclaw.agent, llm.invoke, and llm_task.invoke (they are Lobster pipeline stages, not shell executables).
  • Use run: only for real binaries in your shell (for example openclaw.invoke).

Example (stdin from a prior step is passed to the LLM as artifacts):

steps:
  - id: make_words
    run: echo "One two three four five six"

  - id: count_words
    pipeline: llm_task.invoke --prompt "How many words have been pasted below?"
    stdin: $make_words.stdout

Calling OpenClaw tools from workflows

Shell run: steps execute in your system shell, so OpenClaw tool calls there must be real executables.

If you install Lobster via npm/pnpm, it installs a small shim executable named:

  • openclaw.invoke (preferred)
  • clawd.invoke (alias)

These shims forward to the Lobster pipeline command of the same name.

Example: invoke llm-task

Prereqs:

  • OPENCLAW_URL points at a running OpenClaw gateway
  • optionally OPENCLAW_TOKEN if auth is enabled
export OPENCLAW_URL=http://127.0.0.1:18789
# export OPENCLAW_TOKEN=...

In a workflow:

name: hello-world
steps:
  - id: greeting
    run: >
      openclaw.invoke --tool llm-task --action json --args-json '{"prompt":"Hello"}'

Passing data between steps (no temp files)

Use stdin: $stepId.stdout to pipe output from one step into the next.

Args and shell-safety

${arg} substitution is a raw string replace into the shell command text.

For anything that may contain quotes, $, backticks, or newlines, prefer env vars:

  • every resolved workflow arg is exposed as LOBSTER_ARG_<NAME> (uppercased, non-alnum → _)
  • the full args object is also available as LOBSTER_ARGS_JSON

Example:

args:
  text:
    default: ""
steps:
  - id: safe
    env:
      TEXT: "$LOBSTER_ARG_TEXT"
    command: |
      jq -n --arg text "$TEXT" '{"result": $text}'
S
Description
No description provided
Readme MIT
1.8 MiB
Languages
TypeScript 99.8%
JavaScript 0.2%