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(), byok(), and getReactions() and setReactions({ working, done }) for the account-wide chat reactions.

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 or codex. 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)
Workspaceworkspace, readFile, readFiles, writeFile, renameFile, deleteFile, fileUrl
Public linkscreateFileLink, listFileLinks, revokeFileLink
Scheduled taskslistScheduledTasks, listScheduledTaskRuns, createScheduledTask, replaceScheduledTask, deleteScheduledTask
Keys and sign-inenvKeys, deviceAuth, startDeviceLogin, deviceLogout
TeammatesteammateMessages, fleetMessages({ since, limit }) (every agent-to-agent message on the account)
TerminalopenTerminal, terminalIo, terminalTicket, closeTerminal

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.

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.

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

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, including a free trial of one of them. 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