Skip to content

AI

hutly ai makes a single structured, schema-validated LLM call and returns JSON you can pipe straight into jq. The model’s answer is validated against a JSON Schema you supply, so you get a predictable shape instead of prose to parse.

hutly ai ask <command> [options]

Why this exists

A bash node’s output is stored directly in the workflow’s execution state, which is capped at 256KB. That makes “read a large document, then hand it to an agent node” a dead end for anything long — and pushing the text through the workflow is wasteful even when it fits.

hutly ai ask runs inside the workflow’s sandbox, where the material already is. A bash node can explode a PDF into pages and extract from each one in a shell loop, and only the small structured result ever reaches workflow state.

This is the same primitive workflow function nodes get as hutly.askAI(question, schema, model). Both go through one implementation, so model behaviour, request shape, and billing are identical.

ask

Ask a question and get back JSON conforming to your schema.

# Inline, for short prompts
hutly ai ask "Which state is this agreement governed by?" \
  --schema '{"type":"object","additionalProperties":false,"required":["state"],"properties":{"state":{"type":"string"}}}'

# From files — use this for anything large
hutly ai ask --question-file /tmp/pages/007.md --schema-file /tmp/schema.json --pretty
Option Description
[question] The prompt, as a positional argument
-f, --question-file <path> Read the prompt from a file instead of argv
-s, --schema-file <path> Path to a JSON file holding the response JSON Schema
--schema <json> Inline response JSON Schema
-m, --model <model> gpt5.1 (default) or gpt4.1
--image-file <path> Path to an image file to send with the question (e.g. a rendered PDF page) — repeatable
--pretty Pretty-print the JSON output

Pass the prompt or --question-file, not both; likewise --schema or --schema-file. Prefer the file forms for document text: a page of markdown passed as a shell argument runs into quoting bugs and the ARG_MAX limit.

Response:

{ "result": { "executedDate": "14/03/2024", "page": 7 }, "model": "gpt5.1" }

Image input

For a scanned page, sending the image beats OCR-then-ask: OCR throws away layout and its own errors compound into whatever gets extracted from the text. Sending the page image instead lets the model read the page as it actually looks.

Pass one or more --image-file <path> options alongside the question — each repeat adds another image, in the order given:

hutly ai ask --question-file page.md --schema-file schema.json \
  --image-file /tmp/pages/007.jpg --image-file /tmp/pages/008.jpg

Images are resized server-side (long side capped at 2000px, shortest side capped at 768px) and always re-encoded as JPEG before reaching the model, so any format the pipeline can decode works and there is nothing to declare. Up to 4 images are accepted per call; passing more fails client-side before any request is sent. The cap is deliberately low — a vision call costs roughly 2.4s per image and the request has a hard 29s ceiling, so a page-per-call loop is both faster and safer than batching pages into one request.

Writing the schema

The schema is enforced by the model provider in strict mode, which requires:

  • the root is an object
  • every property is listed in required
  • additionalProperties: false on every object, including nested ones

A schema that breaks these is rejected rather than silently ignored. Ask for the evidence alongside the value — a page number and a short quote — so the result can be checked against the source later:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["executedDate", "page", "snippet"],
  "properties": {
    "executedDate": { "type": "string" },
    "page": { "type": "number" },
    "snippet": { "type": "string" }
  }
}

Example: extract page by page in a workflow

set -euo pipefail
hutly kbs files download "$KBID" "$FILEID" -o /tmp/doc.pdf
PAGES=$(pdfinfo /tmp/doc.pdf | awk '/^Pages:/ {print $2}')

mkdir -p /tmp/pages
for n in $(seq 1 "$PAGES"); do
  python3 -c "
import sys
from pypdf import PdfReader, PdfWriter
r = PdfReader('/tmp/doc.pdf'); w = PdfWriter()
w.add_page(r.pages[int(sys.argv[1]) - 1])
w.write(f'/tmp/pages/{int(sys.argv[1]):03d}.pdf')
" "$n"
  markitdown "/tmp/pages/$(printf '%03d' "$n").pdf" > "/tmp/pages/$(printf '%03d' "$n").md"
  hutly ai ask \
    --question-file "/tmp/pages/$(printf '%03d' "$n").md" \
    --schema-file /tmp/schema.json \
  | jq -c --argjson page "$n" '.result + {page: $page}' >> /tmp/candidates.jsonl
done

jq -s '.' /tmp/candidates.jsonl > "$HUTLY_OUTPUT_FILE"

Only the merged result reaches workflow state; the page text never leaves the sandbox.

Example: extract from scanned pages

For a scanned agreement there is no text layer to extract, so render each page to an image with pdftoppm (poppler-utils) and pass it as --image-file instead:

set -euo pipefail
hutly kbs files download "$KBID" "$FILEID" -o /tmp/doc.pdf

mkdir -p /tmp/pages
pdftoppm -jpeg -r 150 /tmp/doc.pdf /tmp/pages/page

n=0
for f in $(ls /tmp/pages/page-*.jpg | sort -V); do
  n=$((n + 1))
  hutly ai ask "Extract the executed date from this page, if present." \
    --schema-file /tmp/schema.json \
    --image-file "$f" \
  | jq -c --argjson page "$n" '.result + {page: $page}' >> /tmp/candidates.jsonl
done

jq -s '.' /tmp/candidates.jsonl > "$HUTLY_OUTPUT_FILE"

Only the merged result reaches workflow state; the page images never leave the sandbox, same as the text case above.

Billing and traceability

Each call is metered under the workflow_step_llm action meter and charged on total tokens — input and output — exactly like the function-node path. Budget for images accordingly: a page image is almost entirely input tokens, so an image call costs far more than its short JSON answer suggests.

Inside a workflow, the CLI automatically stamps the call with HUTLY_WORKFLOW_EXECUTION_ID, and that value is recorded as the debit’s source_id. A per-page extraction loop therefore produces one ledger row per call, all attributable to the run that caused them. Outside a workflow, each call gets its own generated identifier.

Errors

The command fails loudly rather than returning an empty result. You will get a non-zero exit and a message for: an empty prompt, an unreadable --question-file, --schema-file, or --image-file (the path is named), a schema that is not valid JSON or not an object, a model outside the allowed list, and more than 4 --image-file options in one call — all checked client-side, before any request is sent.

The server’s own failures carry their message through too, so you can tell what to fix:

Cause Status Message
A file that isn’t a decodable image 400 names the index of the offending image
A schema the provider rejects (e.g. a nested object missing additionalProperties: false) 400 the provider’s own complaint
The model returns nothing, or something that isn’t a JSON object 502 the model and what it returned
The organisation is out of credits 402 the credit balance and what is required