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.jpgImages 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: falseon 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 |