Qoni SDK
This page covers installation, all unified SDK method entry points, run and interaction data, and application functions that present requests and submit user responses.
Available SDK
The public unified Qoni SDK is the Node.js and TypeScript package @qoniai/qoni. Its source is available in QoniAI/qoni-sdk-node.
| Item | Current status |
|---|---|
| npm package | @qoniai/qoni |
| GitHub repository | QoniAI/qoni-sdk-node |
| Current public version | 0.9.0 |
| Example version | 0.9.0, matching the npm release; bundled with the download |
| Runtime | Node.js 18 or later with server-side fetch |
| Module formats | ESM, CommonJS, and TypeScript declarations |
| License | MIT |
Server-side credentials
Keep the Qoni accessKey and secretKey on a trusted server. Do not bundle them into browsers, mobile apps, public CLI configuration, or untrusted Agent runtimes.
Published package and pending design
The stable examples on this page use npm 0.9.0. Personal Agent, Enterprise Agent, and hosted sign-in retain the Owner's future Quickstart design. Their real Agent identity field agentId, scopes: ['*'], Agent creation/binding, and fill_form cannot be copied into the published package. 0.9.0 has no handle.submit(); agent (compatible alias agentKey) is an audit label and does not establish Agent identity or user binding. The SDK rejects * locally; list the required scopes explicitly. The automatic run({ memory }) design is pending and returned 422 in the 2026-10-08 gray acceptance test; current integrations must call GUMem's atomic methods separately and verify the results. See Track for its backend contract and unpublished repair candidate (Unreleased, targeting 0.10.0, a breaking change).
Capability surface
The unified SDK exposes these capabilities from Qoni:
| Namespace | Purpose |
|---|---|
genauth | Delegate to Agents, read the current user, and manage GenAuth users |
gumem | Create sessions, add messages, recall Memory, upload resources, and record Actions |
doAnything | Start or reattach to general Web Agent tasks |
webSearch | Search the public web and read results |
deepResearch | Run long-form research and download artifacts |
track | 0.9.0 exposes legacy monitor entries incompatible with the current /track/tracks backend; see Track |
Runtime product calls use short-lived delegation tokens. The SDK handles runtime discovery and downstream product-token exchange. Application code should not construct internal /api/v3/eak/* routes or internal claims.
Install
The downloadable examples and this page use the published 0.9.0 release, including the compatible genauth.delegateAgent() entry and interactions narrowed by type. Use the commands below to install it in an existing application. Download the examples and run npm ci in examples/qoni to install the bundled SDK of the same version.
npm install @qoniai/qoni@0.9.0You can also use pnpm or Yarn:
pnpm add @qoniai/qoni@0.9.0
# or
yarn add @qoniai/qoni@0.9.0Initialize the client
Get an AccessKey
- Open the Qoni Console workspace list and select the workspace that will use the SDK.
- Select AccessKey in the sidebar, then Create AccessKey, and configure the permissions needed for your calls.
- In the Save AccessKey dialog, select Copy credentials. Store the AccessKey ID and AccessKey Secret as the server-side environment variables
QONI_ACCESS_KEYandQONI_SECRET_KEY.
The AccessKey Secret is shown only when the key is created. You can view an existing key's ID in the list, but you cannot retrieve its Secret again. Create a new AccessKey if you did not save the Secret.
import { Qoni, QoniScopes } from "@qoniai/qoni";
// Initialize on the server; AK/SK access the services bound to this workspace.
const qoni = new Qoni({
accessKey: process.env.QONI_ACCESS_KEY!,
secretKey: process.env.QONI_SECRET_KEY!,
});When host is omitted, the SDK uses the hosted gateway https://dashboard.qoni.ai. Private or local deployments can pass host; it must point to the Qoni Console or SDK gateway, not directly to GenAuth, Web Agent, or GUMem.
Qoni accepts the following options:
| Option | Required | Description |
|---|---|---|
accessKey | Yes | Access key created in the Qoni Console |
secretKey | Yes | Secret key created in the Qoni Console |
host | No | Qoni gateway address for private deployments; overrides runtime discovery |
fetch | No | Custom fetch implementation for proxies or test environments |
timeoutMs | No | Per-request timeout, default 30000; event-stream waiting is not limited by it |
sseMaxRetries | No | Event-stream reconnect attempt limit, default 5; set 0 to disable |
Obtain a delegation token
Web Agent and GUMem products act for an end user. Silent delegation requires a real GenAuth user ID from the user pool bound to the Qoni credential:
// Delegate this research task for the current user from the bound GenAuth pool.
const { token } = await qoni.genauth.delegateAgent({
userId: process.env.QONI_USER_ID!,
agentKey: "research-assistant", // Agent audit label for this delegation
scopes: [
QoniScopes.WEB_SEARCH_READ, // Read search status and results
QoniScopes.WEB_SEARCH_MANAGE, // Start or cancel searches
QoniScopes.GUMEM_MEMORY_READ, // Recall user context
QoniScopes.GUMEM_MEMORY_WRITE, // Write confirmed demo preferences
],
expiresIn: "15m", // Converted to the server's integer 900 seconds
});Prefer mode: "interactive" for higher-risk operations such as site login, browser takeover, long-running monitoring, or sensitive artifacts. Complete interactive authorization on the server with completeDelegateToken({ grantId, code, state }); the browser never receives the delegation token.
Fine-grained permissions are declared with scopes in the <namespace>.<resource>:<verb> format, for example webagent.web_search:read. Each Web Agent product has exactly two verbs, read and manage; products: ["webSearch"] requests the whole product as a shorthand. Site sign-in has two more scopes: webagent.site_login:request (open a controlled login browser) and webagent.site_login:confirm (save the login to the browser profile), exported as QoniScopes.SITE_LOGIN_REQUEST / QoniScopes.SITE_LOGIN_CONFIRM. They are not part of any products shorthand: request them explicitly in scopes, and the AccessKey policy must allow them. The SDK exports the QoniScopes constants and QoniScopeBundles presets (such as GUMEM_SESSION_RECALL) so you avoid hand-writing scope strings; a malformed scope throws QoniValidationError locally. For SDK constants and service availability boundaries, see Requestable scopes.
The silent delegation response does not contain grantedScopes. When you need to record the effective permission boundary, use authoritative introspection:
// Declare the fields this page reads; the SDK types introspection data as unknown by default.
const { data: tokenInfo } = await qoni.genauth.introspectDelegationToken<{
active: boolean;
scope?: string[];
}>({ token });
if (!tokenInfo.active) throw new Error("Delegation token is inactive");
const effectiveScopes = tokenInfo.scope ?? []; // Record effective scopes with the audit dataIn an interactive response, state is a compatibility alias of the server-generated grantState; it is not an echo of the caller input state. Verify that the authorization URL's grant_id query value equals the response grantId, then save grantId and your business state on the server keyed by grantState. After consent, the GenAuth callback to redirectUri carries code, your business state, and grant_state (equal to grantState), but no grantId: look the record up by grant_state, check the business state, then call completeDelegateToken({ grantId, code, state }) with the saved grantId and the callback's grant_state as state.
Requestable scopes
A scope passed to delegateToken or genauth.delegateAgent must clear two checks:
- The AccessKey policy: a delegation can request only scopes the current AccessKey allows; anything else returns
eak.delegation.scope_not_allowed. The permission policies you select when creating or editing the AccessKey in Console define that allowlist. - The product API check: GUMem and Web Agent endpoints admit requests by scope. The "Allows" column below states what each scope actually permits and names the SDK methods that use it.
The tables list SDK scope constants and their intended contracts; a constant does not prove that a deployment has registered or implemented it. User Profile, payment, and Agent inbox capabilities remain pending design; legacy verbs from earlier versions are covered after the tables. The SDK constants live on QoniScopes, for example QoniScopes.GUMEM_MEMORY_READ.
GUMem
| Scope | SDK constant | Allows | Console policy |
|---|---|---|---|
gumem.memory:read | GUMEM_MEMORY_READ | Read session context and the message stream; also satisfies gumem.action:read and gumem.profile:read. SDK method: gumem.recall | GUMem Memory |
gumem.memory:write | GUMEM_MEMORY_WRITE | Write Memory; also satisfies gumem.session:create, gumem.message:write, gumem.resource:write, and gumem.action:write. SDK methods: gumem.createSession, gumem.addMessages, gumem.uploadResource | GUMem Memory |
gumem.session:create | GUMEM_SESSION_CREATE | Create a session | GUMem Session |
gumem.message:write | GUMEM_MESSAGE_WRITE | Write messages to a session | GUMem Session |
gumem.resource:write | GUMEM_RESOURCE_WRITE | Upload resource files | GUMem Admin |
gumem.action:write | GUMEM_ACTION_WRITE | Record user Actions. SDK method: gumem.actions.record | GUMem Actions |
gumem.action:read | GUMEM_ACTION_READ | Query user Actions and their progress, facts, summaries, and topics; subscribe to the Action stream. SDK methods: gumem.actions.recall, gumem.actions.stream | GUMem Actions |
gumem.profile:read | GUMEM_PROFILE_READ | Read and recall the user profile | GUMem Admin |
gumem.admin:manage | GUMEM_ADMIN_MANAGE | Call the GUMem console's project management APIs | GUMem Admin |
gumem.memory:delete | GUMEM_MEMORY_DELETE | Reserved; no endpoint checks it yet, so requesting it adds no permission | GUMem Memory |
gumem.search:run | GUMEM_SEARCH_RUN | Reserved; no endpoint checks it yet, so requesting it adds no permission | GUMem Search |
Web Agent
| Scope | SDK constant | Allows | Console policy |
|---|---|---|---|
webagent.do_anything:read | DO_ANYTHING_READ | Read DoAnything run status, events, artifacts, and recordings | WebAgent Do Anything |
webagent.do_anything:manage | DO_ANYTHING_MANAGE | Start and cancel runs, respond to interactions, send messages; includes read | WebAgent Do Anything |
webagent.web_search:read | WEB_SEARCH_READ | Read search status and results | WebAgent Web Search |
webagent.web_search:manage | WEB_SEARCH_MANAGE | Start and cancel searches; includes read | WebAgent Web Search |
webagent.deep_research:read | DEEP_RESEARCH_READ | Read research status, events, and report artifacts | WebAgent Deep Research |
webagent.deep_research:manage | DEEP_RESEARCH_MANAGE | Start and cancel research, respond to interactions; includes read | WebAgent Deep Research |
webagent.track:read | TRACK_READ | Read monitors and their executions | WebAgent Track |
webagent.track:manage | TRACK_MANAGE | Create, update, pause, resume, run now, and delete monitors; includes read | WebAgent Track |
Among the Web Agent SDK methods, reads (status, events, results, artifacts) use read; every operation that changes execution state uses manage. Each product's read + manage pair can also be requested with the products shorthand: doAnything, webSearch, deepResearch, track.
User Profile and Agent inbox (pending design)
0.9.0 exports these constants, but all five scope requests were rejected with eak.delegation.scope_not_allowed in the 2026-10-08 gray acceptance test. The table retains their design intent; it does not claim completion in the published package or deployment.
| Scope | SDK constant | Allows | Console policy |
|---|---|---|---|
user.profile:read | USER_PROFILE_READ | Read the name, phone and shipping address in the user's Profile so DoAnything can fill in checkout | GenAuth User Profile |
user.profile:write | USER_PROFILE_WRITE | Save the fields the user fills in during a fill-in (fill_form) interaction that carry a profileField to their Profile. Design method: handle.submit (absent from 0.9.0) | GenAuth User Profile |
user.payment:use | USER_PAYMENT_USE | After the user allows the payment in a confirmation (confirmation) interaction, the Web Agent runtime fetches the card from the Profile and fills it in the controlled browser; the card number is never returned to your app or the model | GenAuth Payment |
agent.mail:read | AGENT_MAIL_READ | Read mail in the Agent's own inbox, such as verification codes and order confirmations | GenAuth Agent Mail |
agent.mail:send | AGENT_MAIL_SEND | Send mail from the Agent's own inbox | GenAuth Agent Mail |
These scopes serve consumer scenarios; see Personal Agent for the full flow. In the future design, * requests every scope the AccessKey policy allows. 0.9.0 rejects * locally, so current callers must list scopes explicitly. Silent mode has no consent page; interactive authorization has a user confirmation step.
When you request scopes:
- Broader scopes include narrower ones: a Web Agent
managescope includes the same product'sread; GUMemgumem.memory:writeandgumem.memory:readcover the narrower scopes noted in the table. Request only what the task needs. - The SDK exchanges a product token per method: before each GUMem or Web Agent call, the SDK exchanges the delegation for a product token with only the scopes that method needs. If the delegation lacks one, the exchange returns
eak.token_exchange.scope_not_delegated. With the SDK, request the scope listed for the SDK method you call. For example, delegating onlygumem.session:createstill failsgumem.createSession; delegategumem.memory:writeinstead. - Site sign-in:
webagent.site_login:request(QoniScopes.SITE_LOGIN_REQUEST) andwebagent.site_login:confirm(QoniScopes.SITE_LOGIN_CONFIRM) letopenLogin()open a controlled login browser and save the login after confirmation. Noproductsshorthand includes them. Availability depends on deployment registration and the AccessKey policy. The 2026-10-08 gray test issued these scopes, but exchanging them for a Web Agent product token returned 400. Issuance alone does not prove the sign-in flow works. - Legacy verbs: Console's WebAgent policies also list scopes ending in
:run,:stop, and:control. These are verbs from earlier versions. Web Agent endpoints check onlyreadandmanage, so requesting them adds no permission; do not use them in new code.
SDK method index
This page follows the declarations for the bundled @qoniai/qoni 0.9.0 build. Ordinary requests return QoniResponse<T>: data holds the business result and meta holds request, trace, and audit identifiers. The compatible genauth.delegateAgent() entry returns business data directly. Task creation returns a handle for continued reading. Application interaction functions are explained below.
Client and GenAuth
| Method | Input and purpose | Return |
|---|---|---|
qoni.genauth.delegateAgent(input) | Same authorization endpoint as delegateToken; accepts userId, agentKey, explicit scopes, and expiresIn: '15m' | Direct grant.token/grantId/auditId in silent mode, or the authorization request in interactive mode |
qoni.delegateToken(input) | mode, agent, products/scopes, expiresIn, optional idempotencyKey; silent mode uses user, interactive mode uses redirectUri/state | Delegation result or authorization request in data |
qoni.delegateAgent(input) | Existing root alias of delegateToken; distinct from genauth.delegateAgent | Retains QoniResponse, with token at response.data.token |
qoni.completeDelegateToken(input) | Server-saved grantId (the callback does not carry it), callback code, and callback grant_state passed as state | data.token, grantId, auditId, and other delegation fields |
qoni.currentUser({ accessToken }) | Read the signed-in user; equivalent to genauth.userInfo | QoniResponse<T> |
qoni.resolveAnyBoundUser() | Pick a bound user for demos; production apps identify the actual current user | User ID string |
qoni.genauth.userInfo({ accessToken }) | Read identity with the user's GenAuth Access Token | QoniResponse<T> |
qoni.genauth.discovery() / jwks() | Read OIDC discovery configuration / verification keys | QoniResponse<T> |
qoni.genauth.introspectDelegationToken({ token }) | Read delegation validity and effective scopes | QoniResponse<T> |
qoni.genauth.users.list(input?) | page, limit, options; list bound-pool users | QoniResponse<T> |
qoni.genauth.users.get({ userId }) / getBatch({ userIds }) | Read one / multiple users | QoniResponse<T> |
qoni.genauth.users.create(input) / createBatch(input) | User fields / batch users or list; fields follow the GenAuth user API | QoniResponse<T> |
qoni.genauth.users.update({ userId, ...fields }) | Update the specified user's fields | QoniResponse<T> |
qoni.genauth.users.deleteBatch({ userIds }) | Delete selected users after the application confirms the scope | QoniResponse<T> |
users.* exchanges AK/SK for management access to the bound user pool by default, with optional adminToken/userPoolId overrides. User fields and most GenAuth results are generic data; declare T for the fields your application reads and validate results.
For the values the delegation methods accept in scopes and what each scope allows, see Requestable scopes.
GUMem
These methods use a delegated token. Effective Session, Memory, and Action permissions follow the service contract.
| Method | Main input | Purpose and return |
|---|---|---|
qoni.gumem.createSession(input) | token; optional userId, sessionId, title, metadata | Create a Session; QoniResponse<T> |
qoni.gumem.addMessages(input) | token, sessionId, messages; optional userId, sync | Write user-confirmed messages; QoniResponse<T> |
qoni.gumem.recall(input) | token; optional sessionId, query, details, recallConfig, metadataFilters | Recall context; QoniResponse<T> |
qoni.gumem.uploadResource(input) | token, file (Blob/File); optional user, Session, filename, and content type | Upload a resource; QoniResponse<T> |
qoni.gumem.actions.record(input) | token and business Action fields | Record an Action; QoniResponse<T> |
qoni.gumem.actions.recall(input) | token and query fields | Query Actions; QoniResponse<T> |
qoni.gumem.actions.stream(input) | token and query fields | Read the Action stream endpoint result; QoniResponse<T>, not AsyncIterable |
Cross-session recall follows the Memory record's scope. User preferences extracted as scope=user can enter user context across Sessions; facts classified as scope=session (such as a task's project name or budget) do not enter another Session's user context by default. Verify message writes, extraction, scope classification, and recall separately; not every session fact automatically becomes long-term user memory.
Web Agent product entry points
| Method | Main input | Return and limits |
|---|---|---|
qoni.doAnything.run(input) | token, task prompt; optional capture, limits, session, profileId, browserProxy, keepAlive, allowedActions, skills | RunHandle<DoAnythingEvent> |
qoni.doAnything.attach(runId, options) | Existing run ID, token; optional session/capture | Reattach without creating a new task |
qoni.doAnything.artifacts({ token, runId }) | Delegation token and run ID; optional signal | Artifact[] without replaying events |
qoni.webSearch.run(input) | token, prompt (string or string array); optional maxResultsPerQuery, siteWhitelist, siteBlacklist, capture | RunHandle<WebSearchEvent>; no session/limits support |
qoni.webSearch.attach(runId, options) | Existing run ID, token; optional capture | Reattach to the search |
qoni.deepResearch.run(input) | token, prompt; optional depth, outputFormat, targetAudience, domainWhitelist, domainBlacklist, session, limits, capture | RunHandle<DeepResearchEvent> |
qoni.deepResearch.attach(runId, options) | Existing run ID, token; optional capture | Reattach to the research |
qoni.track.create(input) | token, monitoring intent prompt, and supported monitor-definition fields | The 0.9.0 legacy route is unusable with the current backend; the repair candidate returns MonitorHandle |
qoni.track.attach(monitorId, { token }) | Existing monitor ID and delegation token | Reattach to the monitor |
capture supports screenshots/videoFrames; limits declares maxDurationMinutes; session uses { sessionId }. DoAnything browserProxy accepts proxyId, mode (off/all/scoped), and region; research depth is light/standard/deep. Declared or forwarded options do not guarantee that a deployment enables the capability. DoAnything rejects platform options such as model and outputSchema.
Run, monitor, and artifact handles
| Member | Usage |
|---|---|
run.id / run.sessionRef | Retain the run ID / optional Session reference for queries or follow-up runs |
run.status() | Read RunStatus: id/status/sessionId/output/raw |
run.events(options?) | AsyncIterable; accepts lastEventId, signal, onWireEvent, onReconnect |
run.wait(options?) | Return terminal RunResult; accepts timeoutMs/signal, onEvent/onWireEvent/onReconnect, onScreenshot/onInteraction |
run.interactionHandle(request) | Wrap event request data in a handle bound to this run |
run.cancel(reason?) | Request cancellation and return status; distinct from stopping client-side waiting |
monitor.id / monitor.get() | Retain the monitor ID / read its definition |
monitor.pause() / resume() | Pause / resume monitoring |
monitor.refine(patch) | The repair candidate changes only title/schedule, preserving the original intent; legacy DSL is unsupported |
monitor.runNow() | The repair candidate returns trackId/sessionId/runId, meaning accepted; first_look or another in-flight check causes 409 task_in_progress |
monitor.events({ lastEventId?, signal? }) | No current Track SSE endpoint; the repair candidate throws QoniUnsupportedError locally on iteration, with zero HTTP |
monitor.interactionHandle(request) | No current Track question/intervention endpoint; the repair candidate throws QoniUnsupportedError locally |
monitor.runs({ limit?, offset? }) / run(runId) | The repair candidate reads the most recent 50 checks; limit/offset paginate locally within this window; an exact run ID outside it fails |
monitor.delete() | The repair candidate soft-deletes and stops future scheduling; it does not cancel a running task |
artifact.content() | Download bytes (Uint8Array) |
artifact.refreshDownloadUrl?.() | Refresh a temporary URL when the implementation provides this method |
RunResult includes runId/status/output/artifacts/terminalReason/isTaskSuccessful/raw; validate business output. Artifact metadata is id/name/mime/sizeBytes/createdAt/downloadUrl/expiresIn, all optional except id. Timing out or aborting a client read does not cancel server execution: use attach() to continue reading or cancel() to request cancellation.
Monitor rows describe a breaking Unreleased candidate targeting 0.10.0, not available npm 0.9.x functionality. Track creation starts first_look immediately; successful checks use done, without mapping to RunResult.status value succeeded. See Track for the contract.
event.type values include core progress/message/interaction/screenshot/browserLiveUrlChanged/done, search resultsReady, research phase/sectionReady, and legacy monitor type constants monitorCreated/triggered/checkCompleted (current Track has no event stream to subscribe to). Constant keys use PascalCase: for example, QoniEventTypes.Done has the value done. Product handles have different event types; no product emits every type. RunImage contains bytes/mime and optional pageUrl/step. Original frames are available at event.raw; subscribe to all wire frames with onWireEvent.
Low-level and compatibility entry points
Prefer typed methods above. These are actual exposed low-level interfaces; api bodies and results follow the backend and are outside the stable semantic contract.
| Entry point | Methods |
|---|---|
qoni.doAnything.api | createSession, createRun, getRun, events, intervene, cancel, readArtifacts, listArtifacts, artifactDownloadUrl, readRecording |
qoni.webSearch.api | run, get, events, cancel |
qoni.deepResearch.api | run, get, events, followUp, cancel, feedback, listArtifacts, getArtifact |
qoni.track.api | createMonitor, getMonitor, runNow, events, intervene, listRuns, getRun, updateMonitor, deleteMonitor |
qoni.unstableRequest(input) / request(input) | Gateway request with method/path and optional token/query/body/headers; both use the same implementation |
| Signing exports | buildStringToSign(method, path, headers, params), buildSignature(secretKey, stringToSign), buildAuthorization(accessKey, secretKey, stringToSign) |
doAnything.api.readArtifacts is the legacy structured snapshot interface. The current backend has no implementation; 0.9.0 requests the old route and returns 404. The repair candidate marks it deprecated and throws QoniUnsupportedError locally, with zero HTTP. For files use doAnything.artifacts({ token, runId }) / artifact.content(); for browser screenshots use capture: { screenshots: true } and onScreenshot. Neither implements the legacy structured snapshot.
qoni.qoni.delegateToken/completeDelegateToken are also callable; prefer the client methods. delegateAgent/completeDelegateAgent and old credential/token fields are compatibility entries discussed in migration notes below. QoniScopes exports scope constants, QoniProductScopes maps products to scopes, and QoniScopeBundles supplies presets; exported input, result, event, and error types follow these APIs. Advanced deployments can override individual discovery URLs with genauthHost/gumemHost/webAgentHost; ordinary integrations use host.
Interaction requests and business functions
When a task needs the user, the Agent pauses and raises an interaction, then waits for the application to send back the user's decision. Interactions are typed by what the user has to do, not by business: draw one screen per type and the application handles interactions from any task. This section explains each type's data, the SDK methods it offers, and how one application wires them into its own UI. The business functions used by the Quickstart script in the downloadable examples (quickstart.ts) are application code in the downloadable interaction-demo.ts, not exports from @qoniai/qoni.
| Type | What the user does | SDK methods |
|---|---|---|
site_login | Signs in to a site in the controlled browser | openLogin(), confirmSignedIn() |
take_control | Operates the browser by hand for anything other than signing in, such as a CAPTCHA | connectControl(), refreshControl(), releaseControl() |
ask_user | Answers a question shaped by answerType and options | answer(), skip() |
confirmation | Allows or rejects an action | confirm(), reject() |
wait | Nothing to decide: the system is waiting for a rate limit or an external condition | retry() |
Keep three things in mind. For the future design (including fill_form, absent from the published package), see Personal Agent: run DoAnything and handle interactions.
- One interaction calls back several times: creation, status changes, and reconnect replays all call
onInteractionagain. Act only onstatuspendingand dedupe byrequest.id; update one UI card perrequest.id. - Interactions arrive one at a time: the task pauses at an interaction; the Agent continues, and the next interaction appears, only after the current one is handled.
- Methods follow the actions the backend offers: do not call a method when
handle.can(kind)returnsfalse; doing so throwsQoniValidationError.
Handle and request data
onInteraction receives an SDK InteractionHandle, named handle here. request = handle.interaction contains data. handle.id/type/status/actions are convenient field accessors; read title/prompt/payload from request. Response methods are already bound to the run and declared action endpoint, so applications do not construct URLs. The second callback argument is event (RunEvent); use event.runId to associate the task. Quickstart uses only the first argument.
request field | Application usage |
|---|---|
id | Stable request ID for updates and replay |
type | Choose a sign-in, question, approval, or other business view |
status | pending, active, resolved, expired, or canceled |
title / prompt? | Card title / optional explanation |
createdAt / resolvedAt? / expiresAt? | Creation, resolution, and expiration times |
evidence? | Optional { artifactId } evidence reference |
payload | Type-specific business data below |
actions | Currently offered actions, each with kind/label/method/endpoint and optional inputSchema |
A question request with demo values, showing only the main fields:
{
"id": "demo-question-1", // Retained across updates to this request
"type": "ask_user", // The Agent needs the user to answer a question
"status": "pending", // Waiting for the user's response
"title": "Please clarify the scope",
"payload": {
"question": "Only support issues, or all customer questions?",
"answerType": "single_choice", // Text box, number, yes/no, single or multiple choice
"options": [
{ "value": "support", "label": "Support issues only" },
{ "value": "all", "label": "All customer questions" }
]
},
"actions": [
{ "kind": "answer", "label": "Submit answer" } // method, endpoint, etc. omitted
]
}Data and responses for the five request types
type | payload | What the application does |
|---|---|---|
site_login | sites: [{ siteId, displayName, loginUrl }], optional monitorId | Finish controlled sign-in, then call handle.confirmSignedIn() to request a recheck; when openLogin() opened the login UI, it saves that login first |
ask_user | question, answerType, optional options: [{ value, label }] | Send the actual answer through handle.answer(value), typed by answerType; use skip() only when offered |
confirmation | summary | Approve with handle.confirm() or reject with handle.reject() |
take_control | liveUrl, optional surface/reason | Open the browser already handed to the user, then call handle.releaseControl() when finished |
wait | waitKind (rate_limit/external/sleep), optional until/retryable | Show waiting status; call handle.retry() only when offered and chosen by the user |
answerType is text, number, boolean, single_choice, or multiple_choice; answer() takes a string, a number, a boolean, one option value, or an array of option values respectively, and throws QoniValidationError before sending a value that does not fit. The wire still calls this type clarification; since SDK 0.9.0 it is presented as ask_user, and a question with choices is single_choice. Interaction is a union over type, so switch (request.type) narrows payload in each branch. A request keeps its type for its whole lifecycle; when the backend settles it with an empty shell, the SDK keeps the original payload and only changes status. retryable: true is a data hint; actions still decides whether to enable a retry button.
Example application: five business functions
DemoRequestCard describes application display data and button callbacks. Logs show a summary; connect these data to your own card or dialog renderer. demoScreen is local, single-task storage. All five functions return synchronously without waiting for user input: wait() awaits its callbacks before consuming the next event.
// Application demo, not SDK API. Rendering never submits a user decision.
import { QoniValidationError, type ActionKind, type AskUserAnswer, type Interaction, type InteractionHandle,
type InteractionPayloadByType, type InteractionType, type OpenLoginResult } from '@qoniai/qoni'
type DemoPayload = Interaction['payload'] | undefined
type Replies = Partial<Record<ActionKind, (input?: AskUserAnswer) => Promise<void>>>export interface DemoRequestCard {
id: string // Stable request ID
type: string // Original business type
status: string // Latest server status
title: string
prompt?: string | null
content: Record<string, unknown> // Application display data
disabled: boolean // Disable after close or submission
buttons: {
kind: ActionKind
label: string // Backend button label
onUserClick(input?: AskUserAnswer): Promise<void> // Triggered by the user
}[]
}
// Local, single-task demo screen. Scope production storage by user and run.
export const demoScreen = new Map<string, DemoRequestCard>()1. Request site sign-in
Display sites and let the user sign in through a controlled login UI, then click the signed-in button. confirmSignedIn() requests a recheck; it does not prove login success. The controlled login UI can be the deployment's flow, or the app can open it with openLogin({ siteId }) (below).
export function requestUserLogin(payload: DemoPayload, handle: InteractionHandle): void {
// Each site has siteId, displayName and loginUrl. Integrate the deployment's controlled login UI.
renderRequest(handle, 'Sign in to the requested sites', () => ({
sites: payloadFor(payload, handle, 'site_login').sites,
}), {
// Click after signing in. The SDK saves the login openLogin() opened, then asks the Agent to recheck.
confirm_signed_in: () => handle.confirmSignedIn(),
})
// Register the opener when open_login is offered; openSiteLogin() calls it only after a user click.
// A closed or locked request invalidates pending and current logins; same-ID updates are the same request.
const card = demoScreen.get(handle.id)
if (card && !card.disabled && handle.can('open_login')) {
loginOpeners.set(handle.id, (siteId, profileId) => handle.openLogin({ siteId, profileId }))
} else {
loginOpeners.delete(handle.id)
invalidateLogins(handle.id)
}
}Open the login browser from the app. When the backend offers open_login, handle.openLogin({ siteId }) starts a controlled login browser for the site the user chose and returns liveUrl and related fields. Show liveUrl only to the current user and never log it. After the user signs in, call confirmSignedIn(): since SDK 0.9.0 it first saves this login to the browser profile (later tasks reuse it), then asks the Agent to recheck and continue. Sign-in takes these two calls:
| Situation | What confirmSignedIn() does |
|---|---|
| The user is signed in | Saves the login; the Agent rechecks and continues |
| The user did not actually sign in | Still asks the Agent to recheck; the Agent raises a new site_login, which the app shows as a new card under a new request ID |
The backend no longer holds the session (HTTP 409 no_pending_login, for example because it already saved the login) | Same: asks the Agent to recheck |
The backend could not save it yet (probeResult: "release_failed"; the session is kept) | Throws a retryable QoniError coded site_login.save_failed and does not notify the Agent; calling it again saves again |
| Any other save error (for example a missing scope or a network failure) | Throws a QoniError and does not notify the Agent |
openLogin() and saving the login need QoniScopes.SITE_LOGIN_REQUEST and QoniScopes.SITE_LOGIN_CONFIRM respectively. Request both explicitly in scopes when delegating, and allow them in the AccessKey policy; without either one the SDK's token exchange is rejected. However many times it is called, one login session is saved once and the Agent is asked to recheck once. Handles from the same RunHandle share the login session, so confirmSignedIn() on the handle of a later callback also saves this login; a RunHandle from a new attach() does not. confirm() on the openLogin() result is deprecated and shares the same save with confirmSignedIn().
The two application functions below back the UI's open-login and finished-sign-in buttons. Finishing calls confirmSignedIn() through the same card's signed-in button, so the card's submission lock still applies. If the request has already ended when it is called, or the login session is not the latest one opened for this request, finishSiteLogin() rejects without saving the login or notifying the Agent. It also rejects while another login browser for the request is still opening; finish in the new browser once it is shown.
// The app can open a controlled login browser for the chosen site; after sign-in, confirmSignedIn() saves it and asks the Agent to recheck.
const loginOpeners = new Map<string, (siteId: string, profileId?: string) => Promise<OpenLoginResult>>()
// Each request's current login: replaced only when a newer open succeeds; a failed open keeps it; finish it once.
const currentLogins = new Map<string, { attempt: number; login: OpenLoginResult }>()
// Attempt counter: a late older result never overrides a newer successful one.
const loginAttempts = new Map<string, number>()
// Bumped when the request ends or locks: every open in flight is void.
const loginEpochs = new Map<string, number>()
// Login browsers still opening: finishing meanwhile could save a login the SDK has already replaced.
const loginsOpening = new Map<string, number>()
function invalidateLogins(requestId: string): void {
loginEpochs.set(requestId, (loginEpochs.get(requestId) ?? 0) + 1)
currentLogins.delete(requestId)
}
function signedInButton(requestId: string) {
// Only the current, actionable card can finish sign-in; closed or submitted cards have no button.
const card = demoScreen.get(requestId)
return card && !card.disabled ? card.buttons.find(button => button.kind === 'confirm_signed_in') : undefined
}
export async function openSiteLogin(requestId: string, siteId: string, profileId?: string): Promise<OpenLoginResult> {
// siteId must come from the current card. Show the returned liveUrl only to this user; never log it.
const card = demoScreen.get(requestId)
const open = loginOpeners.get(requestId)
const sites = (card?.content.sites ?? []) as { siteId: string }[]
if (!card || card.disabled || !open) throw new Error('This request cannot open a login browser')
if (!sites.some(site => site.siteId === siteId)) throw new Error('Choose a site listed in this request')
const epoch = loginEpochs.get(requestId) ?? 0
const attempt = (loginAttempts.get(requestId) ?? 0) + 1
loginAttempts.set(requestId, attempt)
loginsOpening.set(requestId, (loginsOpening.get(requestId) ?? 0) + 1)
let login: OpenLoginResult
try {
login = await open(siteId, profileId)
} finally {
loginsOpening.set(requestId, (loginsOpening.get(requestId) ?? 1) - 1)
}
// The request ended while opening: this session does not become current.
if ((loginEpochs.get(requestId) ?? 0) !== epoch || !signedInButton(requestId)) {
throw new Error('This sign-in request is no longer actionable')
}
// A newer open already succeeded: this late result does not override it. If the newer one failed, this becomes current.
const current = currentLogins.get(requestId)
if (current && current.attempt > attempt) throw new Error('A newer login browser was opened')
currentLogins.set(requestId, { attempt, login })
return login
}
export async function finishSiteLogin(requestId: string, login: OpenLoginResult): Promise<void> {
// Call after the user signs in at liveUrl. Only the request's latest, still-actionable login session is accepted.
const button = signedInButton(requestId)
if (currentLogins.get(requestId)?.login !== login || !button) throw new Error('This sign-in request is no longer actionable')
// The user opened another login browser that is still opening: finish in the newest one once it is shown.
if ((loginsOpening.get(requestId) ?? 0) > 0) throw new Error('Another login browser is still opening for this request')
currentLogins.delete(requestId)
// The "signed in" button calls confirmSignedIn(): the SDK saves this login, then the Agent rechecks and asks again if still signed out.
await button.onUserClick()
}2. Ask the user a question
Display question in a dialog and render it by answerType as a text box, number input, yes/no switch, single choice, or multiple choice; show each option's label and submit its value. Pass the user's answer to onUserClick(answer), which calls handle.answer(answer). The demo never substitutes a canned answer such as “only support issues.”
export function askForTaskDetails(payload: DemoPayload, handle: InteractionHandle): void {
// question asks for missing task details; answerType picks the control: text, number, yes/no, single or multiple choice.
renderRequest(handle, 'Clarify the task scope or requirements', () => {
const question = payloadFor(payload, handle, 'ask_user')
// Options are { value, label }: show label, submit value
return { ...question, choices: (question.options ?? []).map(option => option.value) }
}, {
// Submit the user's answer; its type follows answerType. The demo never generates an answer on their behalf.
answer: value => handle.answer(value!),
skip: () => handle.skip(), // Show only when offered
})
}3. Request approval
Display summary. Only the user's approve or reject click invokes confirm() or reject(); rendering the card does not approve the plan.
export function requestUserApproval(payload: DemoPayload, handle: InteractionHandle): void {
// summary describes the proposed operation for the user to review.
renderRequest(handle, 'Review and approve or reject the plan', () => ({
summary: payloadFor(payload, handle, 'confirmation').summary,
}), {
confirm: () => handle.confirm(), // User approves
reject: () => handle.reject(), // User rejects
})
}4. Offer browser control
Display liveUrl and the manual-step reason. The user clicks finished after completing the operation. The demo keeps the browser URL in card data rather than logs because it may carry access to the current browser.
export function offerBrowserControl(payload: DemoPayload, handle: InteractionHandle): void {
// liveUrl opens the browser already handed to the user; reason explains the manual step.
renderRequest(handle, 'Complete a manual step in the browser', () => ({
...payloadFor(payload, handle, 'take_control'),
}), {
// Click when finished to hand control back. The browser URL is not written to demo logs.
release_control: () => handle.releaseControl(),
})
}5. Show waiting status
Explain rate limiting, external conditions, or sleep, plus the optional expected recovery time. Without an offered retry action, the card is informational. When retry is offered, the user decides whether to invoke it.
export function showWaitingStatus(payload: DemoPayload, handle: InteractionHandle): void {
// waitKind is rate_limit, external or sleep; until is an optional expected end time.
renderRequest(handle, 'Show why the task is waiting', () => ({
...payloadFor(payload, handle, 'wait'),
}), {
// Show retry only when offered and invoke it only after the user's choice.
retry: () => handle.retry(),
})
}Connect user actions to SDK methods
buttons[].onUserClick is an application callback that invokes an actual SDK method. Pass submitted question text to the answer button callback, or invoke the selected approval button after the user's decision. Display functions never invoke these callbacks themselves.
For a question form, connect the application function submitTaskAnswer(requestId, answer): the request ID comes from the displayed request, and the answer comes from the user's control. It retrieves the latest card button, which invokes the actual handle.answer(answer). This is also application code, not an SDK method.
export async function submitTaskAnswer(requestId: string, answer: AskUserAnswer): Promise<void> {
// Application form handler: find the current card instead of retaining an old event's button.
const button = demoScreen.get(requestId)?.buttons.find(button => button.kind === 'answer')
if (!button) throw new Error('This request is not accepting an answer')
// answer comes from the user's control. The callback validates it, then calls handle.answer(answer).
return button.onUserClick(answer)
}In a Web application, retain callbacks and SDK handles on a trusted server. The frontend displays required data and submits the request ID, action, and input to the application's own business endpoint. The server verifies the current user and run ownership, retrieves the latest request, and responds. Do not expose AK/SK or the SDK client to the browser.
The shared implementation has four responsibilities:
- Replace a card by
idand rebuild buttons from currentactions; stale buttons cannot submit. - Display
pending/activerequests. Terminal updates clear buttons and retain original content; terminal-only replay creates no card. Closure is final, so later nonterminal replay is ignored. - Validate user input and the offered action, then lock the request against double clicks and replayed submissions.
- Propagate submission errors without automatic retries or re-enabling old buttons. The one exception is a local SDK check that fails before sending (
QoniValidationErrorwithout an HTTPstatus, such as text for a number question): nothing was sent, so the demo unlocks the card for the user to correct and resubmit. An error with astatuscame from the server after the request was sent, so the card stays locked. This local demo submits each request only once and does not automatically reopen buttons on later nonterminal events; failure is never treated as success.
One-response demo limit: SDK interaction data has no request revision number or typed method to query one interaction’s current state; updates arrive through interaction events. This demo retains its submission lock, so it cannot reopen sign-in or retry buttons when the same ID is offered again. Use Console or the deployment UI for renewed requests or uncertain submission outcomes. Production apps design recovery from server events and their own idempotency strategy. This limit belongs to the demo, not to SDK methods such as confirmSignedIn().
Shared card updates, payload narrowing, and response implementation
// One-response local demo: a submitted ID stays locked. This is not an SDK restriction.
const submitted = new Set<string>()
const finished = new Set<string>()
function closeFinishedRequest(handle: InteractionHandle): boolean {
// Closure is final. Old pending/active replay cannot revive even a terminal-only request.
if (finished.has(handle.id)) return true
if (handle.status === 'pending' || handle.status === 'active') return false
finished.add(handle.id)
const card = demoScreen.get(handle.id)
// Terminal updates only change status; keep the original content (run handles fold them; hand-built handles may not).
if (card) { card.status = handle.status; card.disabled = true; card.buttons = [] }
console.log({ id: handle.id, status: handle.status, actions: [] })
return true
}
function renderRequest(handle: InteractionHandle, scenario: string,
content: () => Record<string, unknown>, replies: Replies): void {
// Handle closure before reading payload; return immediately so the SDK can keep receiving events.
if (closeFinishedRequest(handle)) return
const card: DemoRequestCard = {
id: handle.id, type: handle.type, status: handle.status,
title: handle.interaction.title, prompt: handle.interaction.prompt,
content: content(), disabled: submitted.has(handle.id), buttons: [],
}
if (!card.disabled) {
card.buttons = handle.actions.filter(action => replies[action.kind]).map(action => ({
kind: action.kind, label: action.label,
onUserClick: input => submitUserChoice(card, handle, action.kind, input, replies[action.kind]!),
}))
}
// Replace the card for this ID and rebuild buttons from the latest offered actions.
demoScreen.set(handle.id, card)
console.log({ scenario, id: card.id, type: card.type, status: card.status,
title: card.title, actions: card.buttons.map(({ kind, label }) => ({ kind, label })) })
}
async function submitUserChoice(card: DemoRequestCard, handle: InteractionHandle,
kind: ActionKind, input: AskUserAnswer | undefined, send: (input?: AskUserAnswer) => Promise<void>): Promise<void> {
// Reject stale cards, closed requests and double clicks; the SDK also checks offered actions.
if (demoScreen.get(card.id) !== card || card.disabled || submitted.has(card.id) || !handle.can(kind)) {
throw new Error('This request card is no longer actionable')
}
// A login browser for this request is still opening: wait and finish in it (both the card button and finishSiteLogin()).
if (kind === 'confirm_signed_in' && (loginsOpening.get(card.id) ?? 0) > 0) {
throw new Error('Another login browser is still opening for this request')
}
if (kind === 'answer') {
// Choices accept option values only (an empty string or an empty selection is legal); free text must not be blank.
const choices = card.content.choices as string[]
if (choices.length) {
const picked = Array.isArray(input) ? input : [input]
if (picked.some(value => typeof value !== 'string' || !choices.includes(value))) throw new Error('Choose an offered answer')
} else if (input === undefined || (typeof input === 'string' && !input.trim())) {
throw new Error('Enter an answer first')
}
}
submitted.add(card.id)
card.disabled = true
try {
await send(input)
} catch (error) {
// A local SDK check failed before sending (QoniValidationError without an HTTP status, say text for a number):
// nothing was sent, so unlock the card. An error with a status came from the server after sending; keep the lock.
// Other errors may mean an uncertain POST outcome; do not retry or re-enable old buttons automatically.
if (error instanceof QoniValidationError && error.status === undefined) { submitted.delete(card.id); card.disabled = false }
throw error
}
}
function payloadFor<T extends InteractionType>(payload: DemoPayload, handle: InteractionHandle,
type: T): InteractionPayloadByType[T] {
if (handle.type !== type || !payload || payload !== handle.interaction.payload) {
throw new Error(`Expected a ${type} request with its SDK payload`)
}
// switch (request.type) narrows payload directly; these functions are dispatched by type, so check type and identity, then return it.
return payload as InteractionPayloadByType[T]
}Types outside the five abstract types, such as the backend's secure_input, have no typed payload. The demo directs them to Console or the deployment's specialized UI. Never treat credential requests as questions or collect passwords through answer().
export function showUnsupportedRequest(_payload: DemoPayload, handle: InteractionHandle): void {
// Types outside the five (such as the current backend's secure_input): do not read, answer or collect credentials.
if (closeFinishedRequest(handle)) return
const previous = demoScreen.get(handle.id)
if (previous) { previous.status = handle.status; previous.disabled = true; previous.buttons = [] }
console.log({ id: handle.id, type: handle.type, status: handle.status,
nextStep: 'Use Qoni Console or the deployment-specific interaction UI' })
}Complete InteractionHandle method table
handle.can(kind) checks whether this request offers an action; invoking an unoffered method throws QoniValidationError. The SDK does not replace user consent, replay handling, or application user authentication.
| Method | actions[].kind | Purpose and limits |
|---|---|---|
can(kind) | — | Check whether the current request offers an action |
answer(value) | answer | Submit the answer to a question, typed by answerType and checked locally before sending; not passwords or new credential-reference objects |
skip() | skip | Submit the user's choice to skip |
confirm() / reject() | confirm / reject | Submit approval / rejection |
openLogin({ siteId?, profileId? }) | open_login | Opens a controlled login browser for a site in payload.sites and returns an OpenLoginResult (profileId/siteId/sessionId/liveUrl/raw). siteId may be omitted when one site is listed; pass profileId to re-login an existing profile. Needs SITE_LOGIN_REQUEST |
confirmSignedIn() | confirm_signed_in | Saves the login openLogin() opened (needs SITE_LOGIN_CONFIRM), then asks the Agent to recheck login state; without openLogin() it only asks the Agent |
login.confirm({ displayLabel? }) | — (method on the openLogin() result) | Deprecated: confirmSignedIn() saves automatically. Saves the login and ends this login session; returns a ConfirmLoginResult (profileId/active/state/probeResult/raw). Needs SITE_LOGIN_CONFIRM |
connectControl() | connect_control | Takes over the browser and returns a ControlSession (liveUrl/expiresAt?/status?/raw): liveUrl is the signed control URL for the user; show it only to that user. Call only if offered; the current takeover flow typically does not offer it |
refreshControl() | refresh_control | Gets a new control URL when the current one expires, also as a ControlSession; later events also carry the new browser URL |
releaseControl() | release_control | Hand browser control back after the user finishes |
retry() | retry | Request a retry after the user's choice |
switchProfile() | switch_profile | Exported, but the current backend neither offers nor implements it; not wired in the demo |
ActionKinds, InteractionTypes, and InteractionStatuses are exported constants. Controlled login and takeover also require deployment permissions. Merge updates by id; terminal notifications may change type or omit the original payload, so retain earlier business content.
Download all examples, including interaction-demo.ts, tests, and runtime dependencies. Place interaction-demo.ts beside the Quickstart script. The examples run with tsx, so imports use the .js suffix. Page code and application functions are strictly typechecked against installed declarations. Method names and low-level API membership are checked automatically; table parameter descriptions are manually matched to the published declarations. Button and login-opening tests use the real InteractionHandle with simulated business input and simulated login-service responses; these tests do not constitute acceptance of private-site sign-in.
Complete runnable example
This program calls GenAuth delegation and introspection, Web Search, GUMem write and recall, and DoAnything. It also uses attach() to read the same search run again. Public sources and an isolated demonstration user keep sample preferences separate from business users.
Download the complete examples, or run from the documentation repository:
cd examples/qoni
npm ci
npm run sdkThe downloadable scripts require Node.js 20.11 or later; the SDK itself supports Node.js 18+.
Set server-side QONI_ACCESS_KEY and QONI_SECRET_KEY. Creating an isolated demonstration user requires GenAuth user-management access. Input handling, parsing, file output, and interaction handling are implemented in the package. npm run typecheck checks every source against the installed SDK declarations.
import { QoniScopes } from '@qoniai/qoni'
import { cleanupDemoUser, cliOptions, createContext, delegate, isolateDemoUser, object, parseJsonOutput, readWithRetry, save, saveArtifacts, searchHits, settled, settleRun, withCleanup } from './runtime.js'
// Create a client from server credentials; context belongs to this sample app.
const context = await createContext('sdk', { ...cliOptions(), skipUserResolution: true })
await withCleanup(context, async register => {
// Keep Memory writes under an isolated demo user and delete that user on exit.
register('isolated demonstration user', () => cleanupDemoUser(context))
await isolateDemoUser(context)
const qoni = context.qoni
// One delegation covers search, browser tasks, and the demo user's Memory.
const grant = await delegate(context, 'sdk-demonstration', ['webSearch','doAnything'],
[QoniScopes.GUMEM_MEMORY_READ, QoniScopes.GUMEM_MEMORY_WRITE, QoniScopes.GUMEM_MESSAGE_WRITE])
const token = grant.token
// Query effective scopes for audit; readWithRetry is the sample's read retry helper.
const { data: tokenInfo } = await readWithRetry(context, 'delegation introspection', () => qoni.genauth.introspectDelegationToken({ token }))
if (object(tokenInfo).active !== true) throw new Error('The delegation token is not active')
// Find official Firefox sources, with at most three results per query.
const search = await qoni.webSearch.run({
token,
prompt: 'Mozilla Firefox official features',
maxResultsPerQuery: 3,
})
save(context, 'search-ref.json', { runId: search.id, auditId: grant.auditId })
register('Web Search run', () => search.cancel('SDK demonstration cleanup'))
// wait() reads the final result; searchHits() validates the actual results[].
const result = await settleRun(context, search)
save(context, 'search-result.json', result)
settled(result)
const hits = searchHits(result.output)
// Reattach by the original run ID to inspect the same search after a restart.
const attached = await qoni.webSearch.attach(search.id, { token })
if ((await readWithRetry(context, 'attached run status', () => attached.status())).status !== 'succeeded') throw new Error('The reattached run has not succeeded')
// Create a session for the demo user; writes and recall share its sessionId.
const sessionId = `sdk-demonstration-${Date.now()}`
await qoni.gumem.createSession({
token,
userId: context.userId,
sessionId,
title: 'SDK demonstration',
})
// Write a confirmed demo preference; sync: true requests synchronous processing.
await qoni.gumem.addMessages({ token, userId: context.userId, sessionId, sync: true,
messages: [{ role: 'user', content: 'For this demonstration, my confirmed preference is concise explanations.' }] })
// Recall task-relevant preferences and verify the new concise preference is found.
const { data: memory } = await readWithRetry(context, 'GUMem recall', () => qoni.gumem.recall({ token, sessionId,
query: 'What confirmed explanation preference should be used?', details: true }))
save(context, 'memory.json', { sessionId, memory })
if (!JSON.stringify(memory).includes('concise')) throw new Error('Recall did not include the demo preference just written')
// Ask the Agent to read the actual search results and return one sourced fact.
const run = await qoni.doAnything.run({ token,
prompt: `Read the actual official sources below and provide one concise, supported Firefox feature with its URL. Return only a JSON object with feature and sourceUrl. Sources: ${JSON.stringify(hits)}`,
capture: { screenshots: true } })
save(context, 'run-ref.json', { runId: run.id, session: run.sessionRef })
register('DoAnything run', () => run.cancel('SDK demonstration cleanup'))
// Read the settled result; saveArtifacts() downloads files with Artifact.content().
const summary = await settleRun(context, run)
save(context, 'result.json', summary)
settled(summary)
await saveArtifacts(context, summary)
// feature and sourceUrl are app-defined fields parsed and validated here.
const feature = object(parseJsonOutput(summary.output))
if (typeof feature.feature !== 'string' || !feature.feature || typeof feature.sourceUrl !== 'string' || !/^https:\/\//.test(feature.sourceUrl)) {
throw new Error('The SDK example did not return a product fact with a source URL')
}
// Link search, browser-task, and delegation audit IDs for source traceability.
const report = { passed: true, dataset: 'public-demo', runId: run.id, searchRunId: search.id,
status: summary.status, hits, memorySessionId: sessionId, output: feature,
auditId: grant.auditId, scopes: object(tokenInfo).scope }
save(context, 'report.json', report)
console.log(JSON.stringify(report, null, 2))
})Outputs and run handles
Web Search RunResult.output contains search results[] with fields such as title, url, and snippet; it is not a JSON generator for arbitrary prompts. Pass these results into a subsequent DoAnything task for analysis or drafting.
Current DoAnything output is commonly wrapped as { answer: string }. The application parses JSON from answer and validates required fields and sources. The full response is saved in result.json; invalid output fails instead of producing a fabricated success.
run.wait() returns RunResult with runId, status, output, artifacts, terminalReason, isTaskSuccessful, and raw. Each Artifact exposes content() to download real bytes. downloadUrl is a temporary signed URL and should not be retained or exposed in logs.
See quickstart.ts in the downloadable examples for event and callback consumption: run npm run quickstart in examples/qoni, adding -- --events to use the event stream. onScreenshot receives an image and screenshot index starting at 0; onInteraction receives an SDK handle. Request fields, business functions, and user responses are documented in Interaction requests and business functions.
Local verification and evidence
npm run verifyThe default command runs Quickstart, the SDK program, and 23 browser scenarios, for 25 items. Add --include-track to include the two legacy Track scenarios, for 27 items in total. They use official 0.9.0 and cannot pass against the current /track/tracks backend; they are not repair-candidate examples. It creates an isolated test user and writes a result matrix. Request statuses, run/monitor IDs, scopes, and business outputs come from real services. Missing data, unsuccessful terminal states, unresolved interactions, or invalid output yield a nonzero exit code. Demonstration execution does not claim access to actual private business accounts.
The verifier also requests every source URL cited in the output. It requests only public HTTPS addresses and checks each redirect hop. A missing or unreachable source fails the sample; an anti-bot refusal (401, 403, or 429) is recorded in the matrix as unverified rather than treated as a missing source. Idempotent reads such as introspection, run status, and Memory recall retry at most twice when the SDK marks an error retryable; creating runs, delegating, writing Memory, and responding to interactions are never retried. Since SDK 0.7.0, wait() retries the same kind of error up to twice when it reads the run detail and artifact list after the run ends; if it still fails, the examples call wait() again without callbacks to read the same run's terminal state; the run is not restarted and callbacks do not fire twice.
Source requests check HTTP reachability, not the returned page content; a challenge page with HTTP 200 can pass. Business facts still need review.
passed-unverified means SDK execution and output structure passed while at least one source could not be independently rechecked. The summary lists these scenarios separately; it does not establish factual accuracy.
The inline code blocks on this page are checked verbatim by the documentation repository's page-code verifier. It assembles the page's code in order, type-checks it strictly and runs it with a real AccessKey.
# Run from the documentation repository root (after npm ci in examples/qoni)
npm run qoni:verify-doc-snippets -- --liveBy default, --live runs this page's SDK programs to their verification endpoints.
Error handling
All SDK errors extend QoniError and carry code, status, requestId, traceId, auditId, and a retryable flag. Regular HTTP requests are not retried automatically (since 0.7.0, wait() retries its post-run detail and artifact reads up to twice); your application decides the retry policy for errors with retryable: true. The events() SSE stream reconnects according to sseMaxRetries.
| Error class | Triggered when |
|---|---|
QoniValidationError | Local input validation fails, for example a malformed scope or silent delegation without user |
QoniUnsupportedError | Added only in the Unreleased repair candidate: unsupported legacy Track or snapshot operations throw unsupported.operation locally; absent from the original 0.9.0 package |
QoniAuthError | The accessKey / secretKey signature is rejected |
QoniPermissionDeniedError | The delegation token lacks a required scope (HTTP 403) |
QoniTokenExpiredError | The delegation token has expired |
QoniDelegationRequiredError | A product call is missing token, or the gateway rejects the token |
QoniRateLimitError | Rate limited (HTTP 429) |
QoniTimeoutError | A request or wait() times out; the run continues server-side and can be reattached with attach() |
QoniUpstreamError | A downstream product service fails |
Original Quickstart delegation style
Since 0.8.0, qoni.genauth.delegateAgent() retains the original delegation style. Tasks still use qoni.doAnything.run({ token: grant.token, prompt }). This compatibility applies to the current @qoniai/qoni package; it does not restore old package names or nonexistent server routes.
| Input or result | Current behavior |
|---|---|
userId / user: { id } | Required in silent mode. Existing behavior is retained: a nonempty top-level userId takes precedence, otherwise user.id is used. Supply only one form in application code |
agentKey / agent | Same Agent audit label. Both must agree; this is not an independent Agent identity credential |
expiresIn: '15m' / 900 | Converted to integer seconds before signing; integer s/m/h/d durations or numeric seconds strings, between 60 and 86400 seconds |
scopes | Explicit scopes supported by the server. products remains available and is unioned with scopes, not used to narrow them |
grant.token | genauth.delegateAgent() returns the grant directly. Root delegateToken() retains response.data.token and meta |
Legacy mailbox.*, web.search, web.extract / deniedScopes | Not implemented by this delegation service; rejected rather than broadened to product permissions |
read/manage constrain product APIs, not mailbox actions. "Draft only" is a task instruction; preventing email sending or deletion requires actual application and server enforcement.
Migrating from versions before 0.4.0
0.4.0 (2026-08-18) is a breaking rename release; the server-side wire contract is unchanged:
- The package was renamed from
@eazo/animato@qoniai/qoni, the recommended primary constructor was consolidated asQoni, and environment variables moved to theQONI_*prefix. The public0.4.0package still includes the old long-form constructor export as a compatibility alias; new code should use onlyQoni. - Root
qoni.delegateAgent/qoni.completeDelegateAgentare deprecated in favor ofdelegateToken/completeDelegateToken; the exchange must include the server-savedgrantId(the browser callback does not carry it) and pass the callback'sgrant_stateasstate— the old{ code, state }form is no longer supported. - Since
0.8.0, both top-leveluserIdanduser: { id }are supported; the constructor optionaccessKeyIdis nowaccessKey. - Gateway routes remain under
/api/v3/eak/*, and token claims andeak.*error codes are unchanged; applications should not construct these internal values themselves.
Publication status of other SDKs
As of August 19, 2026, the QoniAI GitHub organization has one public SDK repository: qoni-sdk-node. No unified Qoni SDK for Python, Java, Go, PHP, or C# is currently visible in that organization.
Product-specific GUMem, Web Agent, or GenAuth SDK references elsewhere in these docs are product integration material or historical SDKs. They do not prove that a corresponding unified SDK has been published by the QoniAI organization. Before selecting a production dependency, verify its package registry entry, source repository, and released version instead of inferring publication from an example package name.
Check the installation
Confirm that npm resolves the current package version:
npm view @qoniai/qoni versionThen verify that the application reads QONI_ACCESS_KEY and QONI_SECRET_KEY only on the server and uses a real GenAuth user ID for silent delegation.
Next step
- Read Personal Agent and Enterprise Agent to see identity, action and memory combined in complete scenarios.
- See the
qoni-sdk-nodeREADME for the complete API surface, error types, and event model.