#Structured Outputs
1 messages · Page 1 of 1 (latest)
Idiomatic OpenClaw way is: don’t try to make the chat reply be JSON. Instead, make the model produce structure via a tool.
Two common patterns:
- Tool call = structured output (best if you own the integration)
- Write/use a plugin tool with a schema’d parameter object / return value.
- The agent “returns” structure by calling the tool with typed args (reliable + machine-readable).
- Use the built-in optional
llm-taskplugin for “JSON-only + optional JSON Schema validation”
- Docs: https://docs.openclaw.ai/tools/llm-task
- You enable + allowlist the tool, then call it with
prompt,input, and optionalschema. It parses JSON and can AJV-validate it.
Example (from the docs):
openclaw.invoke --tool llm-task --action json --args-json '{
"prompt": "Given the input email, return intent and draft.",
"input": { "subject": "Hello", "body": "Can you help?" },
"schema": {
"type": "object",
"properties": {
"intent": { "type": "string" },
"draft": { "type": "string" }
},
"required": ["intent", "draft"],
"additionalProperties": false
}
}'
If you tell me where you need the structure (Lobster workflow step? plugin authoring? just want JSON back to some external client?), I can point you at the most “native” approach for that path.
@worthy crystal I need structured JSON before I have the agent make an MCP call to write to a database.
Also I noticed in the docs Allowlist the tool (it is registered with optional: true): for that llm-task tool. Do I have to do this, or is not having it in my deny key enough? Current tools for tha agent looks like: "tools": {
"deny": [
"gateway"
]
}
If you want schema’d JSON before doing a side-effecting MCP write, llm-task is a good fit:
- Step 1: call
llm-taskwith a JSON Schema that matches the exact payload you plan to send to MCP (additionalProperties: false, required fields, enums, etc). - Step 2: take the resulting JSON (it comes back as
details.json) and then make the MCP call to write to your DB. - If the model returns invalid JSON or doesn’t match schema,
llm-taskfails fast (so you can retry/repair before any DB write).
Docs: https://docs.openclaw.ai/tools/llm-task
Re: “optional: true” / allowlist vs deny
Yes — you do need to allowlist it.
llm-task is registered as an optional tool, and optional tools are never auto-enabled. Not having it in deny is not enough.
Add it to either the agent allowlist or the global allowlist, e.g. (agent-level):
{
agents: {
list: [
{
id: "your-agent",
tools: {
allow: ["llm-task"],
deny: ["gateway"]
}
}
]
}
}
This won’t “lock down” core tools unless you also start putting core tools/groups into allow (plugin-only allow entries are treated as plugin opt-ins). Docs on that behavior: https://docs.openclaw.ai/plugins/agent-tools
@worthy crystal top level looks like: "agentToAgent": {
"enabled": true,
"allow": [
"*"
]
},
"allow": [
"llm-task"
]
Right? Just in the tools key alongside something like agentToAgent?
also for the llm task thi sis what I currently have in the prose. How would you amend this?
# --------------------------
output result = session: synthesizer
prompt: """Produce the final JSON output. Return ONLY valid JSON.
{clean_output}
Schema:
{{
"threads": [{{
"name": "string",
"description": "string",
...... ```
Yep — tools.allow sits alongside tools.agentToAgent under the top-level tools key.
So (top-level):
{
tools: {
agentToAgent: { enabled: true, allow: ["*"] },
allow: ["llm-task"],
},
plugins: {
entries: {
"llm-task": { enabled: true }
}
}
}
(You can also do it per-agent via agents.list[].tools.allow, but tools.agentToAgent itself is a top-level tools.* thing.)
Prose: how I’d amend your “Final JSON Output” phase
Two key changes:
- Don’t embed a “shape” like
{ name: "string" }and call it schema —llm-task.schemaexpects real JSON Schema (type,properties,required, etc). - Have the synthesizer call
llm-taskand then return the JSON from the tool result.
Example amendment (conceptually):
# Phase 7: Final JSON Output
output result = session: synthesizer
prompt: """
You must produce JSON for the upcoming MCP DB write.
First, call the `llm-task` tool with:
- prompt: "Convert the INPUT into the output object. Fill all required fields. Do not invent extra keys."
- input: { "clean_output": {clean_output} }
- schema: (the JSON Schema below)
After the tool returns, output EXACTLY the JSON returned by llm-task (no commentary).
JSON Schema:
{
"type": "object",
"properties": {
"threads": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" }
},
"required": ["name", "description"],
"additionalProperties": false
}
}
},
"required": ["threads"],
"additionalProperties": false
}
"""
If you paste the actual schema you want (and what MCP tool args look like), I can tighten the schema (enums, minItems, formats, etc.) so it fails closed before the DB write.
In prose is single curly braces ok for that though? Or does it need to be double
In OpenProse, interpolation is single braces: {varname}.
{{ ... }} isn’t a special “escape” syntax here (that’s Jinja/Handlebars style), so don’t rely on double-braces.
For literal { inside a prompt, you have two good options:
-
Just write the JSON — Prose only interpolates when it sees
{IDENTIFIER}. Plain JSON like{ "type": "object" }is usually fine. -
If you ever accidentally include something that looks like interpolation (e.g. a substring like
{name}inside your JSON/text), escape the brace:\{
(Per the spec: backslash can escape braces.)
Practical tip for big schemas: put the schema in its own variable and interpolate it once:
let schema = """
{
"type": "object",
"properties": { ... }
}
"""
output result = session: synthesizer
prompt: """
Make JSON that matches this schema:
{schema}
"""
Ok then a good way to take that output and have it use an MCP to write to the db?
Good pattern is 3 steps: generate → (optionally) approve → commit.
1) Generate + validate the payload (no side effects)
Use llm-task with a JSON Schema that matches the exact MCP tool args you intend to send (this is the “structured output” step).
Important: llm-task runs with tools disabled, so it cannot talk to MCP itself — that’s by design.
2) (Optional but recommended) show the JSON + get approval
Especially for DB writes. Even a lightweight “here’s what I’m about to write, OK?” saves pain.
3) Make the MCP call (side effect)
You’ve basically got two common ways:
A) If your MCP tool is exposed as an OpenClaw tool
Then just call it directly with the validated object from llm-task (best UX, no shell quoting issues).
B) If you’re calling MCP via mcporter (CLI)
Run it via exec, using the JSON from step 1:
mcporter call <server.tool> --args '<THE_JSON_FROM_llm_task>' --output json
(From the mcporter skill docs: mcporter call <server.tool> --args '{"limit":5}' and “Prefer --output json for machine-readable results.”)
If you tell me which of the two you’re using (direct OpenClaw MCP tool vs mcporter call …) and what the MCP tool name + expected args are, I can sketch the exact Prose phases so the validated JSON cleanly flows into the write step.
Also what's a good way to ensure these proses finish? Just had to ping it with "you die?" and it replied with: Still here! Waiting on the synthesis session (fe266-synthesis). Let me check its status...Synthesis actually finished — I missed the completion event. Let me grab the output and continue to QA...Synthesis complete.
What you’re seeing is pretty common with OpenProse-in-chat: the “VM” is just another LLM turn loop. If it ever ends a turn in a waiting state (or it loses track of what it was waiting for), nothing “ticks” it forward until a new user message arrives — so a ping causes it to re-check and continue.
Ways to make runs finish more reliably:
-
Make every subagent step write a binding + return a short completion marker
- Follow the OpenProse convention: subagents should write outputs to the binding file and return something like:
Binding written: synthesisLocation: .prose/runs/<id>/bindings/synthesis.mdSummary: ...
- This keeps outputs durable + small, and makes it easy for the VM to “see” completion and proceed (and to recover if it gets confused).
- Follow the OpenProse convention: subagents should write outputs to the binding file and return something like:
-
Keep the VM’s context lean
- Don’t have sessions dump huge JSON blobs back into chat; write the big stuff to a file and return a pointer/summary.
- Big outputs increase the chance the VM loses the plot on the next turn.
-
Add an explicit “DONE” terminal step
- Last statement of the program should be a small session that prints something unambiguous like
PROSE_DONE+ where the outputs live. That way you know it truly reached the end.
- Last statement of the program should be a small session that prints something unambiguous like
- Recovery playbook when it stalls
- Instead of “you die?”, ask it something actionable like:
- “Check
.prose/runs/<latest>/state.mdand the binding forfe266-synthesis, then continue from the next statement.”
- “Check
- (Filesystem state is specifically designed so you can resume from
state.md+bindings/if execution gets interrupted.)
- Instead of “you die?”, ask it something actionable like:
If you paste (a) your state backend (filesystem/sqlite/postgres) and (b) one of the “waiting on …” snippets from state.md, I can suggest the smallest change to your .prose program to make it less stall-prone.
The thing is, is that this is going to be running as a background task and I really don't want to require the user to prompt it in any way in order to get it to finish if it does lock up.
If you need this to run unattended (no user “poke” to keep it moving), I’d avoid relying on an in-chat OpenProse VM run as the primary execution mechanism. It’s inherently “LLM turn driven”, so it can end up in a waiting narration state until something triggers another turn.
The two idiomatic ways to make this reliable in OpenClaw:
Option A (recommended): Cron isolated job = one self-contained run
Run the workflow as a cron isolated agentTurn. Cron runs inside the Gateway scheduler and will wake the agent at the right time, run the turn, and deliver output (announce/webhook/none).
Key bits for “never hang forever”:
- Set a job timeout:
payload.timeoutSeconds - Also set per-step timeouts:
llm-task.timeoutMsexec.timeout(if you’re callingmcportervia exec)
Docs:
This turns “it might need a user ping” into “it either completes or times out + logs/fails predictably”.
Option B: Use Lobster for the multi-step runtime (most deterministic)
If what you really have is a multi-step workflow (generate JSON → validate → MCP write → QA → deliver), Lobster is the more “production” choice than OpenProse because it’s a typed workflow runtime with resumability/approvals baked in.
Docs:
- https://docs.openclaw.ai/tools/lobster
- (and your structured step) https://docs.openclaw.ai/tools/llm-task
If you insist on OpenProse in the background
Then you generally need a “watchdog” mechanism (heartbeat or cron) that periodically:
- checks
.prose/runs/*/state.mdfor incomplete runs - continues them (or fails them) automatically
…but at that point you’ve basically reimplemented a scheduler/runtime around an LLM-driven VM, which is why I’d push you to cron isolated (single-run) or Lobster.
If you tell me whether this is a recurring schedule or a one-shot background job per user action, I can suggest the cleanest cron shape (delivery=webhook vs announce) and where to put the timeouts so it fails closed instead of hanging.
This is a one-shot background job per user action
For one-shot background work triggered by a user action, the most idiomatic thing in OpenClaw is: spawn a sub-agent run (sessions_spawn) instead of trying to “keep a Prose VM alive” in the foreground.
Why: subagents are explicitly designed to be non-blocking and then auto-announce completion back to the requester chat when they finish — no user “poke” required. Docs: https://docs.openclaw.ai/tools/subagents
Pattern
- User triggers action → main agent immediately replies “Got it, starting…”
- Main agent calls
sessions_spawnwith a task like:- run
llm-taskto produce/validate the JSON payload (schema-validated) - then do the MCP write (e.g. via
mcporter call … --args '<json>' --output json) - then return a short success/failure summary
- run
Key knobs:
- Set
runTimeoutSecondsso a stuck run doesn’t hang forever. - Set
cleanuptodeleteif you don’t want session-store buildup (or keep if you want audit trails).
When not to use this
If you need “job survives gateway restart / durable queue semantics”, then use cron schedule.kind="at" with at=now as a durable one-shot job. But for “user clicked button → do work in background → report back”, subagent spawn is the sweet spot.