Little documentation
Guides and APIBuild Agents

Asking A Person

Let the agent ask a person a question without stopping, and continue the turn when the answer comes.

Little Harness: published on npm as little-harness@0.2.0-alpha.0 (2026-10-05). Sections labeled UNRELEASED describe source-only changes. Release status

An answer from a person can take minutes or days. With humanInput, the agent asks with the ask_person tool, which returns at once, so the agent keeps working on whatever does not need the answer. When it does need the answer, it calls await_tasks: if the person has answered, it gets the answer; if not, the turn ends as parked. Your app sends the question out, records the reply with answerInputRequest, and resumeParked continues the turn from where it stopped.

agent: ask_person -> { status: "asked", taskId }   (returns at once)
agent: ...keeps working...
agent: await_tasks([taskId])  -> turn parks
you:   deliver(request)       -> Slack, email, your own inbox
you:   answerInputRequest()   -> the reply is recorded
you:   resumeParked()         -> the turn continues; await_tasks returns the answer

Turn It On

import { createHarness, localHost } from "@little-stack/harness";

const harness = createHarness({
  host: localHost({ dataDir: "./data" }),
  model,
  system:
    "You pre-approve loans. When the policy does not cover a case, ask the underwriter.",
  humanInput: {
    deliver: async (request) => {
      await slack.chat.postMessage({
        channel: "#underwriting",
        text: request.question,
        metadata: { sessionId: request.sessionId, requestId: request.requestId },
      });
    },
  },
});

humanInput: true turns it on with the defaults. It needs a host with durable services, such as localHost.

OptionWhat it does
deliver(request, { session })Sends a new question to a person. Called once per question. When it throws, the question still stands and the agent is told sending it failed.
toolNameThe tool's name. Default ask_person.
descriptionThe tool's description: tell the agent when to ask, and whom.
toolSet false to leave the tool out, so only your own tools can ask (see below).

Your app decides how the question reaches a person and who may answer it. The harness records the question and the answer; it does not send messages or check who answered.

What The Agent Sends

ask_person takes:

FieldMeaning
questionThe question, with what the person needs to answer it.
optionsChoices, as { id, label }. The answer comes back as the chosen id. At most 20.
allowFreeformAlso accept a written answer when options are given.
toWho should answer, if the instructions name someone. Passed to deliver; the harness does not route on it.
answerSchemaA JSON Schema for a structured answer, returned as value.

It returns { status: "asked", taskId }. The question is a task like any other background task, so list_tasks, get_task and cancel_task work on it, and await_tasks returns it with its answer:

{
  "status": "satisfied",
  "tasks": [{
    "taskId": "task_3",
    "kind": "input",
    "question": "APP-1008 has a 47% debt-to-income ratio. Approve?",
    "status": "answered",
    "answer": { "option": "approve", "text": "Fine with the co-signer." },
    "answeredBy": "dana@lender.example",
    "answeredAt": "2026-10-14T09:12:03.000Z"
  }]
}

Answer And Continue

When the reply comes in, record it and continue the turns that were waiting:

import { answerInputRequest, resumeParked } from "@little-stack/harness";

// In the handler that receives the underwriter's reply.
await answerInputRequest({
  harness,
  sessionId,
  requestId,
  answer: { option: "approve", text: "Fine with the co-signer." },
  answeredBy: "dana@lender.example",
});

await resumeParked({
  harness,
  onResult: async (result, turn) => {
    if (result.status === "completed") await notifyApplicant(turn.sessionId, result.text);
  },
});

An answer has an option (one of the question's option ids), text, a value (checked against answerSchema), or option with text as a note. answerInputRequest refuses a second answer, an answer to a cancelled question, and an answer that does not fit the question. It returns the question with its new status. If the process stopped while it was recording an answer, call it again with the same answer to finish; a different answer is refused.

listInputRequests({ harness, sessionId? }) lists the questions still waiting, oldest first, for an inbox page or a reminder. Pass status: "all" to include answered and cancelled ones.

resumeParked continues every turn whose wait is over, up to limit (default 10), and returns { resumed, failed }. Each turn continues as a generateHarness turn, with the system prompt and conversation saved when it parked, and await_tasks returning what it waited for. A turn that failed to continue, for example because the model call failed, stays queued; willRetry says so. A call after retryDelayMs (default 15 seconds, doubling after each failure) tries it again, and after maxAttempts failed tries (default 5) it is dropped; attempts counts them. Each try runs again the steps the turn took before it failed, so a tool with side effects can run more than once. Pass sessionId to continue one session's turns only.

Ask From Your Own Tool

A tool can ask too, through ask in its execution context. It returns at once with the question's status. Asking the same question again in the session returns the same question, with its answer once there is one, so the tool can ask on its first call and act on its next:

import { harnessToolContext } from "@little-stack/harness";
import { tool } from "ai";
import { z } from "zod";

const sendEmail = tool({
  description: "Email the applicant. A loan officer approves each email first.",
  inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
  execute: async (input, options) => {
    const approval = await harnessToolContext(options).ask!({
      question: `Send "${input.subject}" to ${input.to}?\n\n${input.body}`,
      options: [{ id: "send", label: "Send" }, { id: "hold", label: "Hold" }],
    });
    if (approval.status === "waiting") {
      return { status: "waiting_for_approval", taskId: approval.taskId };
    }
    if (approval.answer?.option !== "send") return { status: "held" };
    await mailer.send(input);
    return { status: "sent" };
  },
});

The agent sees waiting_for_approval, calls await_tasks with the taskId, and once the officer answers, calls send_email again and gets sent. Pass ask(question, { key }) to decide what counts as the same question, for example to ask the same words again as a new question. With humanInput: { tool: false } your tools can ask but the agent cannot.

When Nobody Answers

await_tasks takes maxWaitMs. Once it passes, resumeParked continues the turn with status: "timed_out" and the task ids still pending; the question stays open, and a later answer is recorded as usual. Time-outs are checked when resumeParked runs, so call it on a timer if your agents set maxWaitMs.

Good To Know

  • A turn that parked while streaming continues as a generateHarness turn. Send its reply from onResult.
  • The continued turn does not keep the first turn's extraBody or output. Pass extraBody to resumeParked; an Input Type's output is used again.
  • If the process stops while resumeParked is continuing a turn, that turn is not tried again.
  • Questions and saved conversations are kept as JSON under the session's data dir (results/).

See Also

On this page