#Structured Outputs

1 messages · Page 1 of 1 (latest)

teal bear
#

What is the idiomatic way to get structured outputs in openclaw?

rough sableBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> âś… Mark Solution

worthy crystalBOT
#

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:

  1. 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).
  1. Use the built-in optional llm-task plugin for “JSON-only + optional JSON Schema validation”

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.

teal bear
#

@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"
]
}

worthy crystalBOT
#

If you want schema’d JSON before doing a side-effecting MCP write, llm-task is a good fit:

  • Step 1: call llm-task with 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-task fails 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

teal bear
#

@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",
...... ```
worthy crystalBOT
#

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:

  1. Don’t embed a “shape” like { name: "string" } and call it schema — llm-task.schema expects real JSON Schema (type, properties, required, etc).
  2. Have the synthesizer call llm-task and 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.

teal bear
#

In prose is single curly braces ok for that though? Or does it need to be double

worthy crystalBOT
#

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:

  1. Just write the JSON — Prose only interpolates when it sees {IDENTIFIER}. Plain JSON like { "type": "object" } is usually fine.

  2. 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}
"""
teal bear
#

Ok then a good way to take that output and have it use an MCP to write to the db?

worthy crystalBOT
#

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.

teal bear
#

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.

worthy crystalBOT
#

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:

  1. 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: synthesis
      • Location: .prose/runs/<id>/bindings/synthesis.md
      • Summary: ...
    • This keeps outputs durable + small, and makes it easy for the VM to “see” completion and proceed (and to recover if it gets confused).
  2. 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.
  3. 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.
#
  1. Recovery playbook when it stalls
    • Instead of “you die?”, ask it something actionable like:
      • “Check .prose/runs/<latest>/state.md and the binding for fe266-synthesis, then continue from the next statement.”
    • (Filesystem state is specifically designed so you can resume from state.md + bindings/ if execution gets interrupted.)

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.

teal bear
#

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.

worthy crystalBOT
#

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.timeoutMs
    • exec.timeout (if you’re calling mcporter via 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:

If you insist on OpenProse in the background

Then you generally need a “watchdog” mechanism (heartbeat or cron) that periodically:

  • checks .prose/runs/*/state.md for 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.

teal bear
#

This is a one-shot background job per user action

worthy crystalBOT
#

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

  1. User triggers action → main agent immediately replies “Got it, starting…”
  2. Main agent calls sessions_spawn with a task like:
    • run llm-task to 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

Key knobs:

  • Set runTimeoutSeconds so a stuck run doesn’t hang forever.
  • Set cleanup to delete if 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.