Skip to content

Async consults (start + poll)

The plain consult() call blocks until the Hub returns, which is the right shape for most agents. But some questions take minutes because a human has to read and think, and you may not want to hold a tool call open that long. For those, humanchain-mcp 0.17.0 adds a fire-and-poll pair.

The pattern

  1. consult_start(...) submits the question and returns immediately with a consult_job_id.
  2. consult_status(consult_job_id) returns the current state. Poll it on your own cadence until the status is terminal.

consult_start

Takes the same inputs as consult. Returns right away:

{
"consult_job_id": "…uuid…",
"status": "QUEUED",
"submitted_at": "2026-05-29T18:00:00Z",
"poll_url": "/api/agent/consult/…/status",
"cancel_url": "/api/agent/consult/…/cancel"
}

consult_status

{
"consult_job_id": "…uuid…",
"status": "ANSWERED",
"submitted_at": "",
"updated_at": "",
"completed_at": "", # set once terminal
"response": { ... ConsultResponse ... }, # present iff status == ANSWERED
"error": null # {code, message} iff status == FAILED
}

Statuses: QUEUED, IN_FLIGHT, ANSWERED, TIMED_OUT, CANCELLED, FAILED, and CLARIFY_PENDING (the human asked your agent for more context before answering).

The terminal states — stop polling once you see one of these:

  • ANSWEREDresponse carries the ConsultResponse. Check response.provenance to tell a human answer from an LLM fallback.
  • TIMED_OUT — no human answered within wait_seconds and you used bypass_on_timeout=false (or require_human=true), so there is no answer. A human can still answer after the deadline: if one does, the status read carries late_answer: true plus late_answer_text — the consult stays honestly TIMED_OUT, but the late human input is not lost.
  • FAILED — an error occurred; error carries {code, message}.
  • CANCELLED — you (or a worker) cancelled the job.

QUEUED, IN_FLIGHT, and CLARIFY_PENDING are non-terminal — keep polling, backing off toward ~30s to be a polite client.

The easy path: the Python SDK

In Python, don’t hand-roll the polling — the humanchain-sdk package hides it. HubClient.consult() fires the consult and waits for the terminal result (it uses the async API under the hood, so it isn’t bound by your HTTP layer’s max-request-duration):

from humanchain import HubClient
async with HubClient.from_env() as client: # reads HC_API_KEY / HC_BACKEND_URL
answer = await client.consult(
"Is this SOW's IP-assignment clause standard for B2B SaaS?",
domain_tags=["legal", "contracts"],
wait_seconds=600,
)
print(answer.provenance, answer.response_text)

consult() returns the ConsultResponse directly and raises a typed HumanChainError on FAILED/TIMED_OUT. Use submit_and_continue() to hold the job handle and poll on your own cadence instead. Either way you never write the polling loop yourself.

For non-Python stacks, the raw fire-and-poll loop is below.

A fire-and-poll loop (HTTP)

import os, time, httpx
B = os.environ.get("HUMANCHAIN_BACKEND", "https://hub.example.com")
H = {"Authorization": f"Bearer {os.environ['HUMANCHAIN_API_KEY']}"}
job = httpx.post(f"{B}/api/agent/consult/start", headers=H, json={
"question": "Is this SOW's IP-assignment clause standard for B2B SaaS?",
"domain_tags": ["legal", "contracts"],
"wait_seconds": 600,
}).json()
jid = job["consult_job_id"]
while True:
s = httpx.get(f"{B}/api/agent/consult/{jid}/status", headers=H).json()
if s["status"] in ("ANSWERED", "TIMED_OUT", "CANCELLED", "FAILED"):
break
time.sleep(5)
if s["status"] == "ANSWERED":
print(s["response"]["response_text"])
else:
print("no answer:", s["status"], s.get("error"))

Cancel and clarify

  • POST /api/agent/consult/{id}/cancel stops a job you no longer need.
  • If status == CLARIFY_PENDING, the human asked a follow-up. Answer it with POST /api/agent/consult/{id}/clarify and a body of {"answer": "..."}, and the consult resumes.

Next step

For the synchronous call and the full response shape, see the consult API reference.