Interactions
Pending requests that need a human before the run can continue. An interaction is one of two kinds:
approval_request: a tool call awaiting approval, created by a tool approval hook returningpause.question: the agent itself asked you something by callingask_question. Enable on the agent withcapabilities: { userInteraction: true }.
Both flow through the same resource. List them, read them, resolve them. See Human in the loop for the resume flow.
Methods
| Method | Route |
|---|---|
| novo.interactions.list({ runId?, status?, kind? }) | GET /v1/interactions |
| novo.interactions.get(id) | GET /v1/interactions/:id |
| novo.interactions.resolve(id, {...}) | POST /v1/interactions/:id/resolve |
| novo.interactions.resolveBatch(rootRunId, { items }) | POST /v1/runs/:rootRunId/interactions/resolve |
Type signature
type InteractionsNamespace = {
list(input?: {
runId?: string;
status?: 'pending' | 'resolved' | 'cancelled';
kind?: 'approval_request' | 'question';
}): Promise<Page<Interaction>>;
get(id: string): Promise<Interaction>;
resolve(id: string, input: ResolveInteractionBody): Promise<Interaction>;
resolveBatch(
rootRunId: string,
input: { items: Array<{ id: string } & ResolveInteractionBody> },
): Promise<{
items: Array<{
id: string | null;
status: 'resolved' | 'already_resolved' | 'conflict' | 'not_found' | 'invalid_body';
}>;
}>;
};
/** One unified body for both kinds. */
type ResolveInteractionBody = {
/**
* One entry per request being answered, keyed by `requestId`.
* Select one choice with `optionId`; select several for a multi-select
* question (`multiple: true`) with `optionIds`. A `requestId` may appear at
* most once. Supply free-form `text` when the request's `allowFreeform` is
* true.
*/
inputResponses?: Array<{
requestId: string;
optionId?: string;
optionIds?: string[];
text?: string;
}>;
/** Abandon the request without answering. */
cancel?: boolean;
/** Echoed back on the resolution. */
reason?: string;
};
Resolve
Both kinds share one body. Pass inputResponses (one entry per request being answered), selecting an option by optionId or supplying free-form text. What goes in each entry's requestId differs by kind.
An approval_request always carries two options, approve and deny. Address the interaction's own id and select one:
await novo.interactions.resolve(interactionId, {
inputResponses: [{ requestId: interactionId, optionId: 'approve' }],
reason: 'approved by on-call engineer',
});
To deny, select 'deny' instead. The run picks up where it left off and the tool either fires (approve) or returns a tool-output-denied chunk (deny). The reason you pass is echoed back verbatim; it is not labelled by source.
For a question, each requestId is an inner id from the payload: a request.questions[].id (or a request.actions[].id), not the interaction's top-level id. Answer each prompt by its own id, selecting an optionId or supplying free-form text:
const interaction = await novo.interactions.get(interactionId);
const questionId = interaction.request.questions[0].id;
await novo.interactions.resolve(interactionId, {
inputResponses: [{ requestId: questionId, text: 'Customer confirmed via phone.' }],
});
A multi-select question (multiple: true) takes several options at once via optionIds:
await novo.interactions.resolve(interactionId, {
inputResponses: [{ requestId: questionId, optionIds: ['invoices', 'receipts'] }],
});
Each requestId may appear at most once. Selecting more than one option for a question that is not multiple returns 400. Read each request.questions[] entry (on the data-novo-input-requested chunk's request.details, or on the Interaction's request) to know its id, whether it is multiple, which options are valid, and whether it accepts free-form text (multiline / allowOther). Addressing the interaction's top-level id for a question resolves to an "Unknown questionId" and returns 400. cancel: true abandons the request without an answer.
List pending
const pending = await novo.interactions.list({ status: 'pending' });
for (const interaction of pending.data) {
console.log(interaction.id, interaction.kind, interaction.runId);
}
Useful for a "things waiting on a human" inbox. Filter by kind to split approvals from questions:
const approvals = await novo.interactions.list({ status: 'pending', kind: 'approval_request' });
const questions = await novo.interactions.list({ status: 'pending', kind: 'question' });
Resolve siblings in a batch
One step can pause on several interactions. Resolve up to 20 independently; the response carries resolved, already_resolved, conflict, not_found, or invalid_body for each item. A bad item does not roll back its siblings, and unresolved siblings keep the run paused.
const result = await novo.interactions.resolveBatch(rootRunId, {
items: [
{
id: first.id,
inputResponses: [
{ requestId: first.id, optionId: 'approve' },
],
},
{ id: second.id, cancel: true, reason: 'request withdrawn' },
],
});
The Interaction shape
| Field | Type | Notes |
|---|---|---|
| id | string | |
| object | 'interaction' | |
| runId | string | The run that issued the tool call; a worker's own id for a worker call. |
| rootRunId | string | Tree root of runId; equals runId for a root run. Maps a worker call back to its root run. |
| workerRef | string \| null | Worker address (wkr_…) for a worker call; null for a root run. Pair with rootRunId for runs.workers.stream. |
| threadId, agentId | string | |
| kind | 'approval_request' \| 'question' | Which primitive opened the interaction. |
| status | 'pending' \| 'resolved' \| 'cancelled' | allow/deny are outcomes on decision, not statuses. |
| stepNumber | number | The assistant step the interaction belongs to. |
| toolName | string | Novo's canonical provider-neutral tool name for the tool that opened the interaction (ask_question for questions). |
| toolCallId | string | The pending call's ID on the step. |
| hookId | string \| null | Approval only. The approval hook that produced the pause. null for questions. |
| input | unknown | Approval only. The tool's input args. |
| preview | Record<string, unknown> | Approval only. An optional summary the engine rendered for humans. |
| decision | 'allow' \| 'deny' \| 'cancel' \| null | Approval outcome. Null while pending. cancel fires when the run was cancelled mid-pause. |
| reason | string \| null | Echo of the reason you passed. A denial carries this verbatim reason only; there is no source attribution. |
| request | object \| null | Question only. The ask_question payload, { title, details?, questions?, actions? }. Each questions[] entry carries its own id, options?, and free-form flags (multiline / allowOther); resolve a question by those inner ids. null for approvals (whose tool call rides input / preview). |
| resolution | object \| null | The recorded response, set after resolve. Carries the selected inputResponses and any reason. |
| expiresAt | string \| null | ISO timestamp at which an unanswered pause is auto-released: the run finalizes cancelled / interaction_timeout and its pending interactions are cancelled. Non-null only while status is pending; null once resolved/cancelled. Computed live from the current park window (already accounts for a longer maxDurationMs), so render a "expires in …" countdown from it instead of hardcoding the window. Re-read the interaction for the freshest value. |
| createdAt, resolvedAt, updatedAt | string | ISO timestamps. |
Errors
404 interaction_not_found: bad ID.409 idempotency_key_conflict: the interaction was already resolved with a different outcome. Re-resolving with the same outcome is idempotent and returns200.
Resolving an interaction whose run has already terminated is tolerated: the call finalizes the record and returns 200 (the run is simply no longer waiting on it).