novo agents

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 returning pause.
  • question: the agent itself asked you something by calling ask_question. Enable on the agent with capabilities: { 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 returns 200.

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).