consult()
consult() is the entire HumanChain API surface in v0.15. One tool,
opinionated defaults, deterministic shape.
This is the reference. For tutorials see Your first call; for when to call, see Integration patterns.
Function signature
consult( question: str, domain_tags: list[str] | None = None, target_responses: int = 1, approval_method: Literal[ "fastest", "consensus", "best_of_n", "all_must_agree" ] = "fastest", wait_seconds: int = 60, bypass_on_timeout: bool = True, require_human: bool = False, reuse_within_seconds: int = 0, context_brief: str | None = None, answer_format: Literal[ "free_text", "mcq", "msq", "rating_scale", "binary" ] = "free_text", options: list[str] | None = None, card: Card | None = None, chain_id: UUID | None = None,) -> ConsultResponseThat’s the complete surface. Thirteen parameters, one return type, no hidden state.
Parameters
question - str, required
The actual question text. Plain English, no special markup. The Hub’s
routing layer reads this verbatim to do domain-tag inference (in case
domain_tags is None) and to embed it for HCKB cache lookups (v0.16+).
Constraints:
- Min length: 10 characters
- Max length: 4000 characters
- Must be a string; no images / binary in v0.15
domain_tags - list[str] | None
The domain(s) this question belongs to. Used for routing - only humans on your chain (or in the public marketplace) who claim expertise in these tags will be considered.
If None, the Hub infers tags from question via embedding similarity
against the canonical domain tree. Inference is fast but imprecise; pass
explicit tags when you can.
Recommended: 1–4 tags, from general to specific. Example: ["legal", "contracts", "saas"].
The canonical tag list lives at
https://hub.example.com/api/domains/tags and is updated as
domains get onboarded. Custom tags are accepted but won’t route to
anyone until at least one human registers expertise on them.
target_responses - int, default 1
How many humans should answer this question. The Hub will route the
question to up to target_responses × 3 candidates (to absorb
no-shows), then close out the round once target_responses valid
responses are in.
Constraints:
- Min: 1
- Max: 5 (in v0.15; will lift in v0.16 with the 5-voter LLM committee)
- Billing: each response costs 1 credit;
target_responses=3costs 3
approval_method - Literal, default "fastest"
How the Hub aggregates multiple responses. Behaviour matrix:
| Value | Behaviour |
|---|---|
"fastest" | First valid response wins, rest discarded. Latency-minimised. |
"consensus" | Wait for all N; surface if agreement ratio ≥ 0.6 across responses. |
"best_of_n" | Wait for all N; surface the one with the highest expertise grading for the matched tags. |
"all_must_agree" | Wait for all N; surface only if unanimous. Else provenance="escalated" and the Hub queues a v0.16 M7 escalation event. |
Practical guidance - see Integration patterns → Aggregation for the full decision tree.
wait_seconds - int, default 60
The wall-clock budget the Hub spends waiting for human responses before
deciding what to do (see bypass_on_timeout and require_human below).
Constraints:
- Min: 10
- Max: 1800 (30 minutes)
- Recommended: 60–120 for interactive agent workflows; 300–600 for batch / overnight workflows where you want a human to actually wake up and answer.
wait_seconds is a soft budget, not a hard timeout - if a human is
mid-typing at the deadline, the Hub gives them a short grace window
(currently 5 seconds) to hit submit.
Your plan clamps the effective wait for fallback-eligible consults: the free
plan caps wait_seconds at 120s, paid plans allow the full 1800s, and the
response echoes the value actually used in effective_request. A
require_human=true (or bypass_on_timeout=false) consult is exempt from the tier
clamp - it has signalled “a human, or nothing,” so the Hub honors your full
wait_seconds (up to 1800s) on every plan, and never substitutes an LLM answer.
For waits longer than a blocking call should hold open, use the async
consult_start + consult_status poll path instead.
bypass_on_timeout - bool, default True
If True (default), wait_seconds elapsing with no human response
triggers the LLM fallback (Groq Llama 3.3 70B) and returns its answer
with provenance="llm_fallback".
If False, the call raises HumanChainTimeout after wait_seconds.
Use this for batch workflows where you’d rather retry tomorrow than
ship an LLM answer.
If require_human=True, bypass_on_timeout is ignored - the Hub
behaves as if it were False.
require_human - bool, default False
If True, the Hub will never fall back to the LLM. If no human
responds within wait_seconds, the call raises
HumanChainNoHumanAvailable.
Use this for irreversible decisions where you’d rather block than proceed on an LLM guess: legal sign-offs, medical green-lights, prod deploys, financial commitments above a threshold.
context_brief - str | None
Optional pre-amble shown to the human responder before the question. Use for things the human needs to know but that aren’t part of the question itself - current state of the codebase, the user’s risk profile, the contract clause being negotiated, etc.
Max length: 2000 characters. Markdown supported.
This isn’t a way to sneak in a second question; the human is graded on
their answer to question, and context_brief is purely framing. It is
delivered to the responder’s card as its own field — not appended to the
question text.
answer_format - Literal, default "free_text" (FB-07)
The shape of the answer you want back, and how the human’s card renders:
answer_format | Human sees | Requires |
|---|---|---|
"free_text" (default) | A text box | — |
"mcq" | Radio buttons — pick one of options | options (≥2) |
"msq" | Checkboxes — pick one or more of options | options (≥2) |
"rating_scale" | A rating input | — |
"binary" | A yes / no choice | — |
For mcq the human’s answer is the chosen option; for msq it’s the list
of chosen options. The question text may contain Markdown — it
renders on the responder’s card.
options - list[str] | None
The choices for answer_format="mcq" or "msq" — at least 2, at most 10.
Omit for the other formats. Passing options without an mcq/msq
answer_format, or mcq/msq with fewer than 2 options, is a validation
error (caught at the request edge).
consult( question="Which datastore should back the events table?", answer_format="mcq", options=["Postgres", "DynamoDB", "ClickHouse"], context_brief="~50k events/sec, mostly append, occasional range scans.",)reuse_within_seconds - int, default 0 (FB-04, interim)
Opt-in answer reuse (“memory”). When > 0, a consult whose question
and context_brief exactly match a human answer you already received
within the last reuse_within_seconds returns that answer immediately —
cached: true, 0 credits, no human re-consulted. 0 (default) always
fires a fresh consult.
Honesty rules, by design:
- only a genuine human answer is reused — never a prior LLM fallback, and never an already-cached answer (no cache-of-a-cache);
- matching is exact (normalised question + context_brief), never semantic — you can never get back a different question’s answer;
- if the reuse lookup fails for any reason the consult fires normally, so reuse can never break a consult.
Use it to avoid paying twice for the same decision inside a short window — a
workflow that re-runs, a retried step. Available on the async consult path
(consult_start / consult_status).
# First call → a real human answer (cached: false)consult_start(question="Approve this $5k refund?", context_brief="VIP, 2nd request", reuse_within_seconds=3600)# A re-run within the hour → the SAME human answer, instantly, 0 credits (cached: true)This is an interim shim over your recent consults; a durable memory store replaces it later. Treat it as a short-window dedup, not long-term recall.
card - Card | None (typed questions)
Ask for a structured answer instead of free text. Provide either
question or card — the card’s prompt.text is the question.
A card is one typed document: a question_type, an optional context
(background plus media/attachments the human can inspect), the prompt, and a
type-matched answer. Six types:
question_type | Human sees | answer shape |
|---|---|---|
open_ended | a text box | {input:"text"} |
dichotomous | two labelled buttons | {input:"binary", choices:[{id,label}×2]} |
mcq | radio buttons | {input:"single_select", options:[{id,label}]} |
msq | checkboxes | {input:"multi_select", options:[{id,label}]} |
ranking | drag to order | {input:"rank", items:[{id,label}]} |
scalar | a slider / stars | {input:"scale", min, max, step} |
consult(card={ "question_type": "mcq", "context": {"brief": "VIP account, 2nd refund this quarter."}, "prompt": {"text": "Which resolution should we offer?"}, "answer": {"input": "single_select", "options": [ {"id": "full", "label": "Full refund"}, {"id": "deny", "label": "Deny, cite policy"}]},})When you send a card, the response carries a typed
aggregate — the per-type tally,
the agreement reading, and the divergence — computed by the method that fits the
type (plurality for mcq, Borda + Kendall’s W for ranking, median + IQR for
scalar, and so on). The flat answer_format / options above are the lighter,
single-question form of the same idea.
chain_id - UUID | None, default None (v0.16.1+)
A chain is a master-owned named group of humans - your enterprise’s
on-call legal team, your portfolio company’s CFO panel, your customer’s
private domain experts. When chain_id is set, the Hub restricts the
candidate pool to ACTIVE members of that chain only. Public
marketplace humans are excluded.
Use it when you want to route a question to a specific, curated set of people rather than the open marketplace.
from uuid import UUID
consult( question="Should we accept the revised IP indemnity clause in §7.2?", chain_id=UUID("00000000-0000-4000-8000-00000000fa01"), domain_tags=["legal", "contracts"], target_responses=2, approval_method="consensus", wait_seconds=600, require_human=True,)How to get a chain_id: for the trial it is the value above, set once via
HUMANCHAIN_CHAIN_ID. (Chain creation and membership management - POST /api/chains/ and POST /api/chains/{id}/invite - are admin-only
operations, not part of the tester path.)
Authorization - the master that owns the bearer key must also own
the chain. Passing a chain_id you don’t own returns
HumanChainValidationError (HTTP 422). The same error is returned for a
chain that simply doesn’t exist - that’s deliberate, to prevent
chain-existence enumeration by an unauthorized master.
Empty chains - if the chain has zero ACTIVE members, the
matchmaker gracefully falls through to the LLM fallback (same code path
as any other no-human-available case). In v0.16.2+ the chain’s
allow_public_fallback flag will let you opt in to bouncing to the
public marketplace before LLM fallback; for now, empty chain →
LLM/timeout per your bypass_on_timeout and require_human settings.
What happens when no human answers — the canonical behaviour
There is one rule, and two flags that only matter when it doesn’t fire.
If a human answers within wait_seconds, you get that human answer
(provenance="human") — always. require_human and bypass_on_timeout
change nothing in that case. They only decide what happens when no human
answers in time, and require_human always wins:
| Human answered in time? | require_human | bypass_on_timeout | Result | provenance / error |
|---|---|---|---|---|
| Yes | any | any | the human’s answer | human |
| No | false | true (default) | LLM fallback answer | llm_fallback (or client_llm with BYO-LLM) |
| No | false | false | error, no answer | HumanChainTimeout |
| No | true | (ignored) | error, no answer | HumanChainNoHumanAvailable |
In words: require_human=true never returns an LLM — it raises
HumanChainNoHumanAvailable if no human makes the window, and it overrides
bypass_on_timeout. With require_human=false, bypass_on_timeout chooses
between an LLM fallback (the default) and a hard HumanChainTimeout.
Return type - ConsultResponse
@dataclass(frozen=True)class ConsultResponse: consult_id: str # "cns_01HXVR..." provenance: Literal[ "human", # at least one human answered "llm_fallback", # LLM filled in "mixed", # consensus across human + LLM "escalated", # all_must_agree failed; M7 queued ] response_text: str # the answer to surface responder_id: str # uuid OR "llm_fallback_v1" responder_handle: str | None # name, team label, or anon token (see Privacy) domain_tags_matched: list[str] # tags actually used for routing confidence: float # 0.0–1.0; responder-reported latency_ms: int # wall-clock for the call cost_credits: int # billing units consumed cached: bool # served from HCKB? (v0.16+) fallback_reason: str | None # set iff provenance == "llm_fallback" raw_responses: list[RawResponse] # one per voter (target_responses long)
# ── The honest transcript (additive + optional). The artifact is the # transcript, not an answer HumanChain authored. See below. ── original_question: str | None # what you asked, verbatim reframed_question: str | None # paid-tier rephrasing the human SAW (else None) disclaimers: list[str] # plain-English honesty notes confidence_detail: ConfidenceIndicator | None # the disclaimed confidence reading aggregate: Aggregate | None # typed-card aggregation (else None)raw_responses is the unaggregated list - useful if you want to display
multiple opinions side-by-side, or if you’re auditing why
approval_method="consensus" made the call it did.
@dataclass(frozen=True)class RawResponse: responder_id: str responder_handle: str | None response_text: str confidence: float submitted_at_ms: int # epoch ms source: Literal["human", "llm_fallback", "client_llm"] # who produced THIS answer
@dataclass(frozen=True)class ConfidenceIndicator: score: float # 0.0–1.0, mirrors `confidence` label: Literal["Low", "Moderate", "High"] agreement: str # "3 of 4 responses agreed; 1 differed" is_calibrated: bool # True once answerers' track records mature note: str # the disclaimerThe typed aggregate (when you sent a card)
For a typed card the response also carries aggregate —
the answers combined by the method that fits the answer type, surfaced honestly:
@dataclass(frozen=True)class Aggregate: question_type: str # open_ended | dichotomous | mcq | msq | ranking | scalar headline: str # the point answer (modal option, median, aggregate ranking…) headline_value: object # the machine value (option id, number, ordered ids…) distribution: dict # the full picture (counts / per-option % / mean-ranks / histogram) agreement: float # 0–1, measured the right way for the type divergence: str # the minority / outliers, in words — never hidden confidence: ConfidenceIndicator # disclaimed; derived from agreement, not correctnessIt is None for a plain free-text consult. The headline is also copied into
response_text, so callers that only read response_text keep working. The
methods per type: plurality (mcq), majority + Wilson interval (dichotomous),
per-option support + Jaccard (msq), Borda / Kemeny + Kendall’s W (ranking),
median + IQR (scalar), semantic clustering (open_ended).
The honest transcript
HumanChain is the mailman, not the author. What consult() returns is a
transcript - the question asked and the answers given, each labeled by who
produced it - not an answer HumanChain wrote. Four fields foreground that:
raw_responses[].source - the per-answer label. "human" is a real person;
"llm_fallback" is HumanChain’s default LLM (no human answered in time);
"client_llm" is your own configured model (see
Bring-your-own-LLM). Never treat a non-"human" row
as a human judgment.
disclaimers - plain-English honesty notes that travel with every response:
that it represents what humans answered and HumanChain doesn’t author or verify
it (accountability rests with the responders); that an AI fallback is present
when one is; and a confidence caveat.
confidence_detail - a disclaimed reading of how much the answerers
agreed, not whether the answer is true. It carries a Low/Moderate/High
label, a plain-words agreement summary, a 0–1 score, and is_calibrated -
which stays False until the answerers have enough marked-outcome history (see
Outcomes & calibration), then flips True
automatically.
original_question / reframed_question - on paid plans HumanChain may
briefly reframe your question so a human can answer it concretely, without
changing the decision you’re asking about. When that happens,
reframed_question is the version the human actually saw and original_question
is your verbatim text; on the free plan (or when no reframe was needed)
reframed_question is None. The meaning is always preserved - the reframe
never injects an opinion or changes the ask.
Routing & transparency — who answers, and how many
How many. target_responses controls how many humans must answer before the
consult closes — the Hub holds it open until that many valid responses are in (or
wait_seconds elapses). Each appears in raw_responses, so len(raw_responses)
is exactly how many people answered, and every row carries its responder_id,
responder_handle (subject to the chain’s visibility mode),
and source. A directed consult (target_member) always closes on the one named
person’s answer.
Who, and in what order. Within a chain, routing is membership-gated: only
ACTIVE members of the chain_id are considered, and domain_tags soft-rank
them (a better tag match is preferred, but an untagged member is never
hard-excluded). On the open marketplace (no chain_id), domain_tags filter to
humans who claim those tags. The Hub never routes to someone who isn’t online, and
never back to the asker. Selection is best-effort on availability + fit, not a
fixed order — treat a response as “a qualified human”, not “this specific human
every time”.
Errors
consult() raises typed exceptions for predictable failure modes:
| Exception | When |
|---|---|
HumanChainAuthError | API key invalid, revoked, or missing. |
HumanChainQuotaExceeded | Free-tier monthly cap (25/month) reached, or a paid-tier rate limit hit. |
HumanChainTimeout | bypass_on_timeout=False and no response in wait_seconds. |
HumanChainNoHumanAvailable | require_human=True and no human responded in time. |
HumanChainEscalated | approval_method="all_must_agree" failed unanimity check. |
HumanChainBackendError | Transient 5xx from the Hub. Retry-safe. |
HumanChainValidationError | One of your inputs is out of bounds (e.g. question too long, target_responses=10, or chain_id is not a chain you own). |
All share the same error.code, so you can catch broadly. Via the MCP
consult tool these surface as a tool error; via the HTTP API they come
back as an { "error": { "code", "message" } } envelope. The handling
pattern is the same either way:
# Pseudocode: branch on the error code the consult tool / HTTP API returns.result = consult(question="...", require_human=True)if result.get("error"): fall_back_to_llm_or_pause(result["error"])Shorthand notation
You’ll see @hc/... calls throughout the docs and marketing material.
That’s purely a documentation shorthand. The mapping:
@hc/<N>/<method>/<wait>s/<bypass-or-strict> →
consult( question=..., target_responses=N, approval_method=method, wait_seconds=wait, bypass_on_timeout=(bypass-or-strict == "bypass"),)Examples:
| Shorthand | Equivalent call |
|---|---|
@hc/1/fastest/60s/bypass | The default - same as consult(question=...) |
@hc/3/consensus/120s/bypass | 3 voters, consensus, 2-minute wait, LLM fallback on timeout |
@hc/3/all_must_agree/600s/strict | High-stakes: 3 humans must unanimously agree, no LLM fallback |
@hc/1/best_of_n/90s/bypass | Niche domain: try 3 candidates, return the highest-ranked answer |
Sandbox mode
Set HUMANCHAIN_MODE=sandbox to get synthetic responses without burning
credits or waking humans. Every consult() call returns immediately with
a structurally-valid ConsultResponse whose response_text is
"[SANDBOX] Synthetic response for: <truncated question>" and whose
provenance is "human" (so your code paths that branch on provenance
still get exercised).
Sandbox mode is free forever. Use it in CI, in agent unit tests, and during integration development.
Rate limits
Free tier:
- 25
consult()calls per calendar month (resets on the 1st, UTC). Hitting the cap raisesHumanChainQuotaExceededwith an upgrade message. wait_secondsclamped to 120s for fallback-eligible consults. Arequire_human/bypass_on_timeout=falseconsult is exempt and gets the full 1800s (it never falls back to an LLM, so it is allowed to wait).- Question framing off (a paid feature - see Pricing).
Paid tier (PRO / MAX):
- No monthly cap; per-call billing.
- Full
wait_seconds, up to 1800s (30 minutes). - Question framing on (route → frame → dig).
Sandbox mode:
- Unmetered, free forever.
Idempotency
If you pass a header Idempotency-Key: <uuid> via the raw HTTP API (or
idempotency_key=... via the Python SDK), the Hub will return the
same ConsultResponse for any duplicate request within 24 hours.
Useful for retry-safe agent loops - your agent can call consult(),
crash mid-flight, restart, retry with the same idempotency key, and get
the same answer it would have gotten the first time. No double-billing,
no duplicate notifications to humans.
Next step
Now that you’ve seen the full surface, walk through the patterns - when should your agent reach for HumanChain in the first place?