Skip to content

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,
) -> ConsultResponse

That’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=3 costs 3

approval_method - Literal, default "fastest"

How the Hub aggregates multiple responses. Behaviour matrix:

ValueBehaviour
"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_formatHuman seesRequires
"free_text" (default)A text box
"mcq"Radio buttons — pick one of optionsoptions (≥2)
"msq"Checkboxes — pick one or more of optionsoptions (≥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_typeHuman seesanswer shape
open_endeda text box{input:"text"}
dichotomoustwo labelled buttons{input:"binary", choices:[{id,label}×2]}
mcqradio buttons{input:"single_select", options:[{id,label}]}
msqcheckboxes{input:"multi_select", options:[{id,label}]}
rankingdrag to order{input:"rank", items:[{id,label}]}
scalara 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_humanbypass_on_timeoutResultprovenance / error
Yesanyanythe human’s answerhuman
Nofalsetrue (default)LLM fallback answerllm_fallback (or client_llm with BYO-LLM)
Nofalsefalseerror, no answerHumanChainTimeout
Notrue(ignored)error, no answerHumanChainNoHumanAvailable

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 disclaimer

The 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 correctness

It 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:

ExceptionWhen
HumanChainAuthErrorAPI key invalid, revoked, or missing.
HumanChainQuotaExceededFree-tier monthly cap (25/month) reached, or a paid-tier rate limit hit.
HumanChainTimeoutbypass_on_timeout=False and no response in wait_seconds.
HumanChainNoHumanAvailablerequire_human=True and no human responded in time.
HumanChainEscalatedapproval_method="all_must_agree" failed unanimity check.
HumanChainBackendErrorTransient 5xx from the Hub. Retry-safe.
HumanChainValidationErrorOne 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:

ShorthandEquivalent call
@hc/1/fastest/60s/bypassThe default - same as consult(question=...)
@hc/3/consensus/120s/bypass3 voters, consensus, 2-minute wait, LLM fallback on timeout
@hc/3/all_must_agree/600s/strictHigh-stakes: 3 humans must unanimously agree, no LLM fallback
@hc/1/best_of_n/90s/bypassNiche 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 raises HumanChainQuotaExceeded with an upgrade message.
  • wait_seconds clamped to 120s for fallback-eligible consults. A require_human / bypass_on_timeout=false consult 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?

Integration patterns →