The Qoren TypeScript SDK

Use @qoren/sdk to drive Qoren from TypeScript: authenticate with a token, manage environments, agents, secrets and webhooks, and await long-running jobs.

On this page

@qoren/sdk is the TypeScript client for the Qoren API. It is what the Qoren command line is built on, so anything the CLI does you can do from your own code: create environments, deploy and message agents, manage vault secrets, webhook triggers and connected tools, and read usage.

Before you start#

  • Node.js 20 or newer (the SDK uses the built-in fetch).
  • A plan that includes API access: Ultimate, Business or Enterprise. The SDK authenticates with an access token, and on any other plan every call is refused (see handle errors). The web console works on every plan.
  • A personal access token. Create one under Settings, CLI tokens, or run qoren login. See create and revoke access tokens.

Install and connect#

npm install @qoren/sdk

The package is @qoren/sdk on npm.

import { Qoren } from "@qoren/sdk";

const qoren = new Qoren({ token: process.env.QOREN_TOKEN });

const environments = await qoren.environments.list();
console.log(environments.map((e) => e.name));

The constructor takes:

OptionMeaning
tokenYour personal access token (qrn_...)
baseUrlThe Qoren server. Default https://qoren.sh
timeoutMsPer-request ceiling. Default 15 minutes, because some calls run for minutes
userAgentSent as User-Agent, so you can tell your client apart in logs
fetchYour own fetch implementation, for tests or instrumentation

Every request goes to Qoren's API gateway, the same one the console and the CLI use. It checks the token, applies your plan's limits and records the action, so the SDK can do exactly what your account can do and nothing more.

Know the vocabulary#

The SDK uses the product's words. Where the API path differs, the method hides it.

SDKAPI pathWhat it is
environmentsmachinesThe private computers agents run on
templatestemplatesWhat an agent is built from
clientscustomersAgency clients you run environments for
account.clients()clientsYour organization's own record, whose slug creating an environment needs

Work with each resource#

Account#

const usage = await qoren.account.usage();       // credits used and left, blocked, budgetPaused
const options = await qoren.account.options();   // sizes, regions, models and their defaults
const models = await qoren.account.models();     // each model's name, description, prices and context
const spend = await qoren.account.spending(30);  // model spend per environment, last 30 days
const costs = await qoren.account.costs({ from: new Date("2026-09-01") }); // per client

usage.blocked means the account is out of credits; usage.budgetPaused means the monthly budget you set was reached. Both stop agents, and they have different fixes: top up for the first, raise or clear the budget for the second. See spend controls.

Other methods: fleetSummary(), clients(), the bring your own key methods below, getReactions() and setReactions({ working, done }) for the account-wide chat reactions, and getChatReplies() and setChatReplies({ slack: "final" }) for the account-wide chat replies.

Bring your own key#

Your account can keep a model key for each of several providers, and each agent runs on one key source: a provider key, Qoren's metered key ("managed"), or none ("none", the agent stops running turns). See bring your own key. Turning BYOK mode on or off is done in the console under Settings, BYOK; there is no SDK method for it.

const byok = await qoren.account.byok();
// billingMode: "managed" | "byok", defaultProvider, agentsTotal, agentsOnByok, agentsOnManaged
// providers: [{ id: "anthropic", configured, maskedKey, runsCodex, isDefault, agents, ... }]

await qoren.account.setByokKey({ provider: "anthropic", key: process.env.ANTHROPIC_API_KEY! });
await qoren.account.setByokDefault("anthropic");

const plan = await qoren.account.planByokMove("anthropic", ["agt_def456"]);
// [{ agentId, name, runtime, from, fromModel, to, model, blocked }]
const results = await qoren.account.moveByokAgents(
  plan.map((p) => ({ agentId: p.agentId, target: "anthropic", model: p.model ?? "claude-sonnet-4-5" })),
);
// [{ agentId, jobId, error }]

Provider ids are openrouter, anthropic, openai, google, xai, deepseek, mistral, groq and custom (exported as BYOK_PROVIDERS). setByokKey checks the key with the provider before storing it and answers 400 when the provider rejects it; provider defaults to openrouter, and custom needs an https baseUrl. The top-level configured, maskedKey and validation on byok() describe the OpenRouter key; providers covers every one.

planByokMove(target, agentIds?) changes nothing. A model of null means the target does not offer the agent's current model, so pick one from byokModels(target), which lists a provider's models read with your key, or the models on Qoren's key for "managed". blocked: "codex" means a Codex agent cannot go there, because the provider has no OpenAI Responses API (Anthropic, Google Gemini and Mistral); move it to "managed" or "none" instead. In moveByokAgents, a move applies at once whether BYOK mode is on or off: a result with a jobId is being applied, one with neither jobId nor error was already there, and one with an error was not made (for example, the agent still being set up). BYOK mode only sets what new agents, Custom agents and Ask Qoren run on.

Other methods: removeByokKey(provider), which answers 409 while agents run on that key, and applyByok(), which brings every agent in line with its key source and returns the job queued for each.

Environments#

const slug = await qoren.resolveOrgSlug();
const { jobId, machineId } = await qoren.environments.create({
  clientSlug: slug,
  name: "production",
  size: "s-2vcpu-2gb-90gb-intel",
});
await qoren.jobs.await(jobId!);

size is a size name ("light", "standard", "heavy", "max") or one of the four plan size slugs (listed in the Qoren command line). A size above your plan is refused with a 409, "That environment size isn't available on your plan." account.options() lists every size the platform knows, not only the ones your plan accepts. Your plan sets the largest size you may use; see environment sizes. Other methods: list(), get(id), rename(id, name), destroy(id), vitals(id), setCollaboration(id, enabled), teammateMessages(id) and assignClient(id, clientId).

Resize an environment#

resize(id, size, plan?) returns { jobId, mode }. A larger size is "in-place": the environment restarts once. A smaller size is "replace": the platform builds a new environment at that size, moves every agent to it with all of its state, and removes the old one. The environment keeps its name but gets a new id, and only one environment is billed throughout. See resize an environment.

Call resizePlan(id, size) first to see what a resize would do: its mode, the target's capacity (0 means no limit), each agent and whether it meetsMinimum for the smaller size, overCapacityBy (how many agents cannot stay), the destinations with free slots, whether an overflow environment is allowed and at which sizes, any warnings, and a blocker when the resize cannot happen at all.

When agents do not fit, pass a plan saying where each extra one goes. Agents you leave out stay:

const preview = await qoren.environments.resizePlan("env_abc123", "light");
if (preview.blocker) throw new Error(preview.blocker);

const { jobId } = await qoren.environments.resize("env_abc123", "light", {
  agents: [
    { agentId: "agt_scout", action: "remove" }, // backup kept for 90 days
    { agentId: "agt_writer", action: "move", targetMachineId: "env_beacon" },
    { agentId: "agt_triage", action: "overflow" },
  ],
  overflowEnvironment: { size: "light", name: "atlas-2" },
});
await qoren.jobs.await(jobId);

An overflow environment is a real environment: it is billed and counts toward your plan's environment limit. A plan that still leaves agents without room is refused with a 409 that QorenError.overCapacity reads (see below).

If a shrink's job fails or is interrupted, every agent is on the original environment or on its destination. resumeResize(id) finishes it from where it got to, and cancelResize(id) moves agents back and removes the replacement. Both return { jobId } and take the id of the original environment or its replacement. While a shrink is under way, the environments involved carry a replacement field with its role, phase and jobId.

Agents#

const options = await qoren.account.options();
const { jobId, safetyWarnings } = await qoren.agents.create({
  machineId: "env_abc123",
  slug: "inbox-triage",
  name: "Inbox triage",
  runtime: "hermes",
  model: options.defaultModel,
  presetName: "inbox-triage",
  clientSecretNames: ["GMAIL_TOKEN"],
});
await qoren.jobs.await(jobId);

const turn = await qoren.agents.message("agt_def456", "What came in today?");
const job = await qoren.jobs.await(turn.jobId);
console.log(job.result); // { exitCode, stdOut, stdErr }

runtime is hermes, openclaw, codex or opencode. slug is the agent's permanent identity and cannot change later; rename changes only the display name. Only secret names cross the wire; values are filled in on the server from your vault.

A message runs one turn and can take minutes, so message returns a job and the reply is the job's result. Pass the session id from a reply as the third argument to continue that conversation.

Retry-safe messages#

The optional fourth argument to agents.message accepts an idempotencyKey:

const submission = await qoren.agents.message(agentId, "Prepare a report", null, {
  idempotencyKey: "report_request_12345678",
});

Keys contain 16 to 128 ASCII letters, numbers, underscores or hyphens. An exact retry by the same user, organization and agent returns the same durable agent-submission job. Reusing the key with a different message or session returns 409. Its successful result contains executionJobId; follow that job to obtain the agent's reply and sessionId. Submission completion means dispatch completed, while execution completion is reported by the execution job. Conversation continuation on this path requires a previous execution result belonging to the same user and agent.

If Qoren refuses the message, for example because the agent's environment is not ready or your plan does not allow the run, the call fails right away with that error and the key stays unused, so you can retry with the same key once the cause is fixed. If a restart interrupts dispatch, the submission can fail with an uncertain outcome. Inspect recent execution jobs before deliberately creating a new submission with a new key. The platform does not automatically replay an uncertain submission. Calls without an idempotency key retain the existing message-job behavior.

Other methods, grouped:

AreaMethods
Lifecyclelist(environmentId?), get, destroy, rename, configure, reprovision, move, snapshots, snapshot, restore, listDeleted, restoreDeleted
AutonomygetAutonomy, setAutonomy (see autonomy); setApprovalMode is deprecated
Runningexec, logs, healthcheck, health (every agent at once), diagnostics, activity, telemetry, usage, doctorRuns, runDoctor
Spend limitsgetSpendLimits, setSpendLimits
Chat reactionsgetReactions, setReactions (see chat reactions)
Chat repliesgetChatReplies, setChatReplies (see chat replies)
Workspaceworkspace, readFile, readFiles, writeFile, renameFile, deleteFile, fileUrl
Public linkscreateFileLink, listFileLinks, revokeFileLink
Scheduled taskslistScheduledTasks, listScheduledTaskRuns, createScheduledTask, replaceScheduledTask, deleteScheduledTask (see task delivery)
Task deliverygetDelivery, setDelivery, listDeliveryChats (see task delivery)
Keys and sign-inenvKeys, deviceAuth, startDeviceLogin, deviceLogout
TeammatesteammateMessages, fleetMessages({ since, limit }) (every agent-to-agent message on the account)
TerminalopenTerminal, terminalIo, terminalTicket, closeTerminal

configure changes the model, the reasoning effort, secrets, MCP servers or chat channels in one job; send only what changes. reasoningEffort is low, medium or high and may be sent on its own:

const { jobId } = await qoren.agents.configure("agt_def456", { reasoningEffort: "low" });
await qoren.jobs.await(jobId);

The agent applies it from its next turn. get returns the owner's choice as reasoningEffort (null until it is changed, so the template's setting applies) and, for Hermes, Codex and OpenCode agents, the template's own as templateReasoningEffort.

exec runs a shell command as the agent's own user and returns { exitCode, stdOut, stdErr } directly.

Each agent can have a daily, a weekly and a monthly spend cap in credits:

const limits = await qoren.agents.getSpendLimits("agt_def456");
// { dailyCredits: 500, weeklyCredits: null, monthlyCredits: 10000,
//   spent: { dailyCredits: 120, weeklyCredits: 400, monthlyCredits: 900 },
//   key: "agent", enforced: true }

await qoren.agents.setSpendLimits("agt_def456", {
  dailyCredits: limits.dailyCredits,
  weeklyCredits: 2000,
  monthlyCredits: limits.monthlyCredits,
});

A cap is a whole number of credits from 1 to 100,000,000, or null for no cap. setSpendLimits replaces all three caps, so to change one, read the current caps first and send the other two back unchanged, as above. Both methods return the caps, the credits spent in the current UTC day, week (Monday to Sunday) and month (spent is null when it could not be read), and two fields that say whether the caps apply:

keyMeaning
agentThe agent has its own Qoren model key and the caps are enforced. Once a cap is reached, model calls stop until the window resets, and resume within a few minutes of it
pendingThe agent's own key is being issued, usually within a few minutes. The caps apply once it lands
ownThe agent runs on your own model key. Qoren does not bill or cap it
noneA Custom agent with no Qoren model key

enforced is true while the caps are being applied to the agent's key. canEdit is true when you may change the caps: only the organization owner can, and setSpendLimits from anyone else fails with a 403 QorenError.

Clients#

For agencies with clients turned on (see clients). Without the feature, these answer 403 with "Clients are not enabled for this account."

const client = await qoren.clients.create({ name: "Acme Dental", contactEmail: "ops@acme.example" });
await qoren.environments.assignClient("env_abc123", client.id);

Other methods: list({ includeArchived }), update(id, input) and archive(id). Archiving is refused while environments are still assigned. getReactions(id) and setReactions(id, { working, done }) read and change the chat reactions of the client's agents, and getChatReplies(id) and setChatReplies(id, { slack: "final" }) their chat replies.

A client hologram link is a public URL that shows only that client's environments and agents, live, with no sign-in, for a screen in their office. hologramLink(id) reads it ({ active, url, createdAt }), createHologramLink(id) creates it or replaces it (the old URL stops working), and revokeHologramLink(id) switches it off. Account owners only; anyone else gets a 403.

For a client with front desk, rotateIntakeToken(id) mints the client's web form token, or replaces it (the old one stops working at once). It returns { token, intakeUrl, snippet, rotatedAt } only this once: intakeUrl is where the client's lead form posts, and snippet is a script tag to paste on their site. Needs the front desk feature and permission to edit clients.

setUpFrontDesk(id) sets a client's front desk up: it seeds the default journeys and draft templates, creates the front desk assistant when it is missing and repairs the scheduler's booking webhooks. The assistant is a Custom agent, so the plan needs the Agent Designer and only the account owner can create it; running it again changes nothing that exists. frontDesk(id) reads the same answer without changing anything: the assistant, the checklist (connections, approved templates and FAQs, booking link, web form token, portal users), the settings, and nextSteps in plain words. updateFrontDeskSettings(id, input) binds the client's Twilio, cal.com and Retell connections and sets its business line, time zone, quiet hours, marketing touch cap, booking link, handoff Slack channel and bookable services; omitted keys stay as they are and null clears one. A cal.com connection must be assigned to the client, and binding it registers its booking webhooks.

The same call sets the client's openingHours ({ mon: [["09:00", "17:00"]], sun: [] }: up to three ranges a day, an empty list for a closed day; it replaces every day, and null clears them), the optional setup steps the agency skipped (skippedSteps: booking, phone or form), and wentLive: true to switch the front desk live, which is refused until texting, the business line and the assistant are in place (wentLive: false undoes it). The settings answer carries them back as openingHours, skippedSteps and wentLiveAt.

Each client has one assistant, built from your account's front desk design. assistant.state says where that design stands: up_to_date on Qoren's current instructions, updating while Qoren's automatic upgrade catches up (it runs on its own, usually within the hour, so there is nothing to run after a Qoren update), customized when your agency edited the design (customizedReason: "edited") or the agent is pinned to an older version ("pinned"), so it is not updated automatically, and missing before setup creates it. resetFrontDeskAssistant(id) puts the design back on Qoren's current instructions and tools, republishes it and sets the client's assistant to follow the latest version. The design is shared by every client of your account, so the reset reaches all of them. It needs permission to edit clients and answers 409 when the client has no assistant. agents still lists the assistant as one front_desk entry for older code; read assistant instead.

The setup pickers read the client's own connections: listSchedulerEventTypes(id, { connectionId }) lists the cal.com event types with each one's length and booking page, plus the public bookingLink; listVoiceAgents(id, { connectionId }) lists the Retell agents (one row per agent, with usesRetellLlm, since only those can take the published prompt) and the selectedAgentId; listSlackChannels(id) lists the channels the client's Slack bot can post handoffs to (connected: false when Slack is not connected). Without connectionId they use the bound connection.

sendTestText(id, { from, body }) texts the client's assistant as if from from, without a phone. It runs the real path (the contact, the assistant, the outbound gate), so the answer is a real SMS to from; at most 10 test texts an hour per client and 30 per account. testTextConversation(id, from) returns that number's conversation (the last 30 messages, oldest first, each marked test when it was a test text) to watch the answer arrive.

frontDeskContent(id) lists the client's message templates and FAQs, each approved or waiting for the client's approval with the live text beside the waiting one. draftFrontDeskFaq(id, { question, answer, key }) drafts a new FAQ, or a new version of one by key; it waits in the client's portal until the client approves it, and agents keep using the live version until then. An agency cannot approve content itself. Reads need permission to view clients, test texts and drafts permission to edit them.

For a client whose Retell voice connection is bound, voicePrompt(id) shows exactly what publishing its voice agent would push: the prompt built from the approved FAQs, opening hours and bookable services, the first line, the mid-call functions, the webhook URL, and inboundWebhookUrl to paste on the Retell number. It calls nothing at Retell. publishVoiceAgent(id, { agentId }) pushes that prompt to the client's own Retell agent and publishes a new version; nothing is ever pushed without this call. agentId is remembered, so later publishes can leave it out. Preview needs permission to view clients, publish permission to edit them.

To see what a client sees in their portal without changing anything, read it as their owner would: portalPreviewHome(id) (what needs a person, with counts, and the setup checklist), portalPreviewInbox(id, filter?, { page, pageSize }) with filter needs_you or all, portalPreviewOutcomes(id, period?), portalPreviewConversation(id, conversationId), portalPreviewTask(id, taskId), portalPreviewClients(id, { q, stage, page, pageSize }), portalPreviewClientStory(id, contactId), portalPreviewAssistant(id) (follow ups with their steps, messages with a preview filled with sample values, and answers), portalPreviewLists(id), portalPreviewList(id, segment) and portalPreviewMe(id). Every answer is marked read only, and there is no way to write through the preview. Needs the front desk feature and permission to view clients.

Chat reactions#

On Telegram, Slack, Discord and Signal an agent reacts to each message it receives with a "working" emoji while it handles it, then swaps it for a "done" emoji once it has replied. Both are set at three levels, each used unless the level below sets its own: the account (qoren.account), a client (qoren.clients) and an agent (qoren.agents). Each slot inherits on its own, and with nothing set anywhere agents use 👀 and ✅.

const view = await qoren.account.getReactions();
// own:       { working: null, done: null }   what this level sets (null inherits)
// effective: { working: "👀", workingSource: "platform", done: "✅", doneSource: "platform" }
// choices:   [{ emoji: "👀", name: "Eyes", telegram: true }, ...]

await qoren.account.setReactions({ working: "🤔", done: view.own.done });
await qoren.clients.setReactions("cus_123", { working: null, done: "🎉" });
const agent = await qoren.agents.getReactions("agt_def456");
await qoren.agents.setReactions("agt_def456", { working: agent.own.working, done: "👍" });

Every getReactions and setReactions returns the same shape: own, inherited (what the level would use with both slots null), effective (what applies, with the level each slot comes from: platform, account, client or agent), inheritedFromName (on an agent, the name of the client it inherits from), canEdit, supported (on an agent, false for Codex, OpenCode and Custom agents, which do not use chat reactions) and choices. setReactions replaces both slots, so to change one, send the other's own value back. An emoji that is not one of choices fails with a 400. A choice with telegram: false is not allowed on Telegram, where agents use 👀 while working and 👍 when done instead. Only the account owner can change the account's reactions; anyone else gets a 403. Agents pick up a change within seconds.

Chat replies#

While an agent handles a message on a chat channel (Telegram, Slack, Discord, Matrix, WhatsApp, Microsoft Teams or Signal), its reply style sets how much of the work the chat sees: "final" sends only the finished reply, "progress" adds the agent's own updates and a heartbeat on long tasks but no tool calls, and "tools" shows each tool the agent runs, as it runs it, then the reply. A style is set per channel at three levels, each used unless the level below sets its own: the account (qoren.account), a client (qoren.clients) and an agent (qoren.agents). Each channel inherits on its own, and with nothing set anywhere agents use "progress".

const view = await qoren.account.getChatReplies();
// channels: [{ channel: "telegram", label: "Telegram", own: null,
//              inherited: { style: "progress", source: "platform" },
//              effective: { style: "progress", source: "platform" } }, ...]
// styles:   [{ id: "final", label: "Final reply only", description: "..." }, ...]

await qoren.account.setChatReplies({ slack: "final", telegram: null });
await qoren.clients.setChatReplies("cus_123", { discord: "tools" });
await qoren.agents.setChatReplies("agt_def456", { slack: "progress" });

Every getChatReplies and setChatReplies returns the same shape: channels, each with channel, label, own (what this level sets, null to inherit), inherited (what the level would use with own set to null) and effective (what applies, with the level it comes from: platform, account, client or agent); then inheritedFromName (the client's name when a channel inherits from the client), canEdit, supported (on an agent, false unless it is a hosted Hermes agent, the only kind that follows reply styles) and styles. The account and a client list every channel; an agent lists only the channels it is set up on, so its list can be empty. The SDK exports the channel ids in order as CHAT_REPLY_CHANNELS and the style ids as CHAT_REPLY_STYLES. setChatReplies changes only the channels you name and keeps the rest; null clears that level's choice so the channel inherits again. An empty object, an unknown channel or an unknown style fails with a 400. Only the account owner can change the account's chat replies, and changing a client's needs permission to edit clients; anyone else gets a 403. Agents use a change from their next message, and nothing restarts.

Task delivery#

Each scheduled task has a destination for its results: "activity-only" keeps them in the agent's activity, "all-channels" also posts them to every chat channel the agent has (Hermes only), and "chat" posts them to the chats you name, up to 8. A chat target is platform for that chat app's home chat, platform:chatId, or platform:chatId:threadId; the SDK exports the pattern as CHAT_TARGET_PATTERN and the limit as MAX_DELIVERY_TARGETS.

const { platforms } = await qoren.agents.listDeliveryChats("agt_def456");
// [{ platform: "slack", label: "Slack", state: "connected", error: null,
//    chats: [{ target: "slack:C0123ABC", name: "#ops", kind: "channel" }] }, ...]

await qoren.agents.createScheduledTask("agt_def456", {
  name: "Morning brief",
  schedule: "0 8 * * 1-5",
  prompt: "Summarize my inbox",
  deliveryTargets: ["slack:C0123ABC", "telegram"],
});

await qoren.agents.setDelivery("agt_def456", { policy: "chat", targets: ["slack:C0123ABC"] });
const delivery = await qoren.agents.getDelivery("agt_def456");
// default:   { policy: "chat", targets: ["slack:C0123ABC"] }
// effective: { policy: "chat", targets: ["slack:C0123ABC"] }

A task returns its chats as deliveryTargets, in order, and an empty list for the other policies; deliveryTarget carries only the first, for code written before a task could have several. When you write a task, deliveryTargets implies "chat" and wins over deliveryTarget. A chat on an app the agent does not have, more than 8 chats, or the same chat twice (a bare platform counts as its home chat) fails with a 400. replaceScheduledTask replaces the whole task, so to keep a task's chats, send its deliveryTargets back.

A task written with neither deliveryPolicy nor chats takes the agent's default destination, which getDelivery and setDelivery read and change. Both return default (the owner's choice, null when none is set), effective (what a task created without a destination gets right now), defaultStale (true when the default names a chat app the agent no longer has, so it is ignored), allChannels (whether "all-channels" is allowed), channels (the agent's chat apps, each with the homeChat Qoren knows as a full target, or null) and supported (false for Codex, OpenCode and Custom agents, which have no chat apps). With no default, or a stale one, a new task goes to every chat channel on a Hermes agent with chat apps and stays in activity on any other. setDelivery takes { policy, targets }; { policy: null } clears the default. The default is copied onto a task only when a write names no destination, so changing it never moves an existing task.

listDeliveryChats reads the chats the agent knows live from its host: the Slack channels its bot is in, the Discord channels it can see, and every chat that has messaged it, each with a target ready for deliveryTargets, plus each app's connection state (connected, retrying, fatal, disabled, connecting or stopped) and last error. Only Hermes keeps this list. For any other agent, before Hermes has written its list, or when the host cannot be reached, it returns available: false with a note saying why rather than failing; a chat can still be named by its id.

Jobs#

list(limit?), get(id), cancel(id) and await(id, onProgress?, signal?). The next section shows how to follow one.

Secrets#

The vault, scoped by your organization slug. Values are write-only: list returns names and details, never values. Each row also carries usedBy, the agents that receive that key (id and name), so you don't need a usedBy call per key.

const slug = await qoren.resolveOrgSlug();
await qoren.secrets.set(slug, { name: "STRIPE_KEY", value: process.env.STRIPE_KEY! });
const who = await qoren.secrets.usedBy(slug, "STRIPE_KEY");

Other methods: list(slug), remove(slug, name), and reveal(slug, name), which returns one value and is recorded in your account log every time. See secrets.

If you work for clients, pass customerId to set to save a key for one client only; each row from list carries customerId and customerName (null for a key saved for all clients). remove, reveal and usedBy take the client id as a third argument to pick that client's key. An agent gets its own client's key first, then the one for all clients, and never another client's.

await qoren.secrets.set(slug, { name: "HUBSPOT_API_KEY", value: acmeKey, customerId: acme.id });
await qoren.secrets.remove(slug, "HUBSPOT_API_KEY", acme.id);

Templates and skills#

const templates = await qoren.templates.list(); // the gallery plus your own
const full = await qoren.templates.get("inbox-triage");

Other methods: templates.save(body) (runs the spend and security review first) and templates.remove(slug); skills.list(), skills.get(slug) and skills.setForAgent(agentId, slugs).

Webhooks#

const { url, secret } = await qoren.webhooks.create("agt_def456", {
  name: "Bookings",
  source: "cal",
  events: ["BOOKING_CREATED"],
  instructions: "Add the attendee to the CRM and brief me.",
});

The URL and secret come back once, from create and rotate; store them. When the agent already has a webhook for that source, create joins it instead: it returns joined: true, secret: null, and in endpointEvents the events that webhook must now send. Pass ownEndpoint: true for a separate URL and secret. conditions (on create and update) runs the trigger only when the payload matches, for example [{ path: "action", equals: "completed" }, { path: "check_suite.conclusion", in: ["failure", "timed_out"] }]; anything else is logged as filtered and costs nothing. Other methods: sources(), list(agentId), get(id), update(id, input), rotate(id), remove(id), test(id), deliveries(id, limit?), delivery(id, deliveryId) and replay(id, deliveryId). See webhooks.

Integrations#

For accounts with Integrations on; anything else answers 403 with a message saying so. Reading is for anyone on the account; connecting, granting, links, triggers and anything that uses a connection's key are for the account owner. Every call goes through the same gateway as the console.

import { integrationKeyRefusal } from "@qoren/sdk";

const catalog = await qoren.integrations.catalog(); // tools, their key fields, events and curated tools

try {
  const { connection } = await qoren.integrations.connections.connect({
    provider: "pipedrive",
    credentials: { apiToken: process.env.PIPEDRIVE_TOKEN! },
    level: "readwrite",
  });
  await qoren.integrations.grants.create(connection.id, {
    agentId: "agt_def456",
    level: "read",
    allowedTools: ["pipedrive_find_person", "pipedrive_get_deal"],
  });
  await qoren.integrations.triggers.create("agt_def456", {
    connectionId: connection.id,
    event: "deal.added",
    instructions: "Research the new lead and add a short note to the deal.",
    deliveryMode: "digest",
    windowSeconds: 900,
  });
} catch (err) {
  const refusal = integrationKeyRefusal(err); // { error, code, probe } when the key was refused
  if (refusal) console.error(refusal.error);
  else throw err;
}

Credential field names come from each tool's auth mode in catalog(), such as apiToken for Pipedrive. The key never comes back: connections carry only keyFingerprint, its last four characters. probe({ provider, credentials }) checks a key without saving it, and a refused connect or replaceKey answers 422, which integrationKeyRefusal(error) reads (it returns null for any other error).

AreaMethods
Top levelcatalog(), probe(input), forAgent(agentId) (the connections an agent may use, with their events)
connectionslist({ customerId, includeDisconnected }) (customerId: "none" for your own), get, connect({ provider, credentials, customerId, level, label }), replaceKey(id, credentials), check(id) (the daily check, now), disconnect(id) (returns where to delete the key), events(id), volume(id, eventId, { maxPerHour })
grantscreate(connectionId, { agentId } or { allInCustomer: true }, plus level, allowedTools, readCapPerHour), update(connectionId, grantId, { level, allowedTools, clearAllowedTools, readCapPerHour }), remove(connectionId, grantId)
linkslist({ customerId }), create({ customerId, provider, level, purpose }) (the URL comes back once), revoke(id)
triggerscreate(agentId, input), wireTemplate(agentId, { grants, template }), decide(triggerId, { approve, note }) for a trigger an agent asked for, switchToConnected(triggerId, { connectionId })
suggestionslist({ connectionId, agentId }), accept(id, { grantLevel, sendTest }), dismiss(id)

triggers.create takes connectionId, event, name, instructions, autonomy (act or propose), maxPerHour, conditions, deliveryMode (each, digest or coalesce), windowSeconds (60 to 86,400), maxBatch (2 to 500, digest only) and sendTest. Once created, a connected trigger is an ordinary trigger: read, edit, pause, test and delete it with qoren.webhooks. Its event is fixed and it has no URL or secret to rotate. The page a client opens from a connect link is not part of the SDK, and the CLI has no integrations commands yet.

Approvals#

const waiting = await qoren.approvals.list(); // status "pending" by default
for (const request of waiting) {
  console.log(request.agentName, request.source, request.displayCommand, request.expiresAt);
}
await qoren.approvals.decide(waiting[0].agentId, [
  { approvalId: waiting[0].id, approve: false, note: "Not this week." },
]);

list({ status, limit }) takes pending, decided or all. A request the agent's autonomy policy approved by itself carries autoApproved: true. decide(agentId, decisions) settles some of one agent's requests and returns the ids of the jobs it started. Only an org owner can decide; anyone else gets a 403 whose code is owner_required. See approve what your agents ask to do.

Autonomy#

Each agent has an autonomy policy that decides how much it does on its own. sources are the kinds of turn (chat, triggers, teammates, scheduled, repair): act runs the turn normally, ask runs it as a proposal that waits in approvals. actions are the kinds of Qoren tool call (spend, schedule, messageAgents, publicLinks, records): allow runs the call right away, ask waits for a person in every turn. autoApprove: "exceptHigh" approves a turn's low and medium risk proposals automatically. Tools that destroy, resize, move or rebuild always ask. New agents start on the balanced preset.

const autonomy = await qoren.agents.getAutonomy("agt_def456");
// { policy: { preset: "balanced", sources: { chat: "act", triggers: "ask", ... },
//             actions: { spend: "ask", messageAgents: "allow", ... }, autoApprove: "none" },
//   effective: { sources: { ... } }, supportsSourceAsk: true, canLoosen: true, notes: [] }

await qoren.agents.setAutonomy("agt_def456", { preset: "cautious" });

await qoren.agents.setAutonomy("agt_def456", {
  policy: {
    ...autonomy.policy,
    sources: { ...autonomy.policy.sources, scheduled: "act" },
  },
});

setAutonomy takes either a whole policy ({ policy }, every row included) or a preset ({ preset: "autonomous" | "balanced" | "cautious" }) and returns the same shape as getAutonomy. The server names the saved policy's preset itself, custom when the rows match none. A change applies from the agent's next turn. effective.sources is what each source does in practice: where the agent's runtime cannot run a kind of turn as a proposal it shows act, and notes says why. Anyone in the organization can make an agent more careful; making it more autonomous (any ask to act or allow, or turning on auto-approve) is for an org owner, and anyone else gets a 403 whose code is owner_required. canLoosen tells you which you are. An unknown value is a 400. Each agent from get and list also carries its policy as autonomy.

setApprovalMode(agentId, enabled) still works but is deprecated: true applies the cautious preset and false the autonomous one, and it returns { approvalModeEnabled }.

Follow a job#

Anything expensive returns { jobId } straight away and does its work in steps. qoren.jobs.await polls until the job finishes, resolves with the finished job, and throws the job's own error if it failed or was cancelled:

const job = await qoren.jobs.await(jobId, (snapshot) => {
  if (!snapshot) return console.log("reconnecting…");
  const done = snapshot.steps.filter((s) => s.status === "Succeeded").length;
  console.log(`${done}/${snapshot.steps.length} ${snapshot.status}`);
});

It polls quickly at first and slows down for long jobs. If the API is briefly unavailable, it keeps waiting and calls your callback with null instead of failing. Pass an AbortSignal as the third argument to stop waiting.

Handle errors#

Every failed call throws a QorenError with the server's message, the HTTP status, and the parsed body.

PropertyTrue or set when
isAuthError401 or 403: the token is missing, revoked, or not allowed this call. False for isApiAccessRequired
isApiAccessRequired403 with code api_access_required: the token is fine, but the organization's plan does not include API access
isPaymentRequired402: this needs an active plan
retryableA brief outage; trying again is safe
codeThe body carries a machine-readable code
regionUnavailableAn environment create was refused because the pinned region cannot run the size
sizeUnavailableA resize was refused because the environment's region cannot run the size
overCapacityA shrink was refused because the agents it would keep do not fit the smaller size: { capacity, agents }, each agent { id, name, slug }

A create that names a region pins it. If that region cannot run the size, you get a 409, and regionUnavailable tells you the ways out:

import { Qoren, QorenError } from "@qoren/sdk";

try {
  await qoren.environments.create({ clientSlug, name, size, region: "nyc1" });
} catch (e) {
  const offer = e instanceof QorenError ? e.regionUnavailable : null;
  if (!offer) throw e;
  // offer.suggestedRegion: the closest region that has the size, or null
  // offer.availableSizes: sizes nyc1 does have, each { slug, key, label }
  if (offer.suggestedRegion) {
    await qoren.environments.create({ clientSlug, name, size, region: "nyc1", autoRegion: true });
  }
}

Leave region out and the platform picks a region that can run the size, with no refusal. resize answers the same way under sizeUnavailable, without a region option, because an environment cannot move; pick one of its availableSizes instead.

When the pre-deploy review refuses an agent or a template, the error is a 422 and body.safetyFindings lists each finding with a suggestion.

When the organization's plan does not include API access, every call is refused the same way, whatever the path. The message names the plans that include it, and the body carries the details:

try {
  await qoren.environments.list();
} catch (e) {
  if (e instanceof QorenError && e.isApiAccessRequired) {
    // e.body: { code: "api_access_required", plan: "pro",
    //           plans: ["Ultimate", "Business", "Enterprise"],
    //           upgradeUrl: "https://qoren.sh/pricing" }
    console.error(e.message);
  }
  throw e;
}

Signing in again does not help; upgrading does. A plan change reaches a token already in use within about 30 seconds.

Call an endpoint the SDK does not wrap#

qoren.raw reaches any endpoint with the same token and rules:

const summary = await qoren.raw("fleetSummary");
const created = await qoren.raw("agents", { method: "POST", body: { /* ... */ } });
const recent = await qoren.raw("machines", { query: { limit: 5 } });

The path is relative to the API root. Operator-only endpoints answer 403 to every customer account.

Frequently asked questions#

Can I use the SDK in a browser?

It runs anywhere fetch exists, but never ship a personal access token to a browser: anyone who can read a token can act as you. Call the SDK from your own server instead.

Where is the SDK published, and is it open source?

On npm, as @qoren/sdk. It is open source under the Apache 2.0 license, and its source is at github.com/qoren-sh/sdk, where you can also report issues.

Why does creating an agent return before the agent exists?

Deploying is a job that installs and configures the agent on its environment in several steps. create returns the job id straight away; await it with qoren.jobs.await.

Which plans can use the SDK?

Ultimate, Business and Enterprise. On Starter, Pro or with no plan, calls throw a QorenError with isApiAccessRequired set. Compare plans on the pricing page.

Does the SDK retry failed calls?

Only while awaiting a job, where brief outages are ridden out. A single call that fails throws; check retryable to decide whether to try again.

Was this page helpful?

Last updated