A TypeScript client for the Quire REST API.
Typed wrappers around fetch, OAuth helpers, and error-formatting utilities for the Quire API. Used by the Quire CLI and other Node-based Quire integrations.
Status: stable since 1.0.0 — the public API follows semver, so a breaking change needs a major version.
npm install @quire-io/api-clientRequires Node.js 20+.
| Module | Exports |
|---|---|
QuireClient |
Authenticated client covering tasks, projects, organizations, comments, chats, documents, insights, dashboards, reminders, partners, sublists, statuses, tags, custom fields, attachments, timelogs, approvals, undo. Auto-refreshes tokens via a caller-supplied callback. See COVERAGE.md for the full per-endpoint table. |
exchangeCode / refreshTokens |
Parametrized OAuth helpers. Supports both confidential clients (pass clientSecret) and public PKCE clients (pass codeVerifier). |
formatQuireError |
Maps an HTTP error response into a short, user-readable string. Knows about Quire's quota / rate-limit / paid-plan signals. |
QuireAuthRevokedError, QuireTokenRefreshError |
Typed errors thrown by QuireClient and the OAuth helpers. |
parseQuireUrl |
Parses a quire.io URL into a { kind, ... } descriptor. |
looksLikeOid |
Distinguishes a 24-char Quire OID from a slug / numeric id. |
resolveColor, COLOR_TABLE, NAMED_COLORS |
Quire's fixed icon-color palette + friendly name lookup. |
| Type definitions | QuireTask, QuireProject, QuireOrganization, QuireUser, QuireRecurrence, etc. |
Register an OAuth client and look up scopes / endpoints in the Quire API docs.
import {
QuireClient,
exchangeCode,
refreshTokens,
} from "@quire-io/api-client";
const tokens = await exchangeCode({
apiServer: "https://quire.io",
clientId: process.env.QUIRE_CLIENT_ID!,
clientSecret: process.env.QUIRE_CLIENT_SECRET!,
code,
redirectUri: "https://your.app/callback",
});
const client = new QuireClient({
tokens,
apiServer: "https://quire.io",
refreshTokens: (refreshToken) =>
refreshTokens({
apiServer: "https://quire.io",
clientId: process.env.QUIRE_CLIENT_ID!,
clientSecret: process.env.QUIRE_CLIENT_SECRET!,
refreshToken,
}),
onTokenRefresh: async (newTokens) => {
await db.saveTokens(userId, newTokens);
},
});
const me = await client.getMe();import {
QuireClient,
exchangeCode,
refreshTokens,
} from "@quire-io/api-client";
// During login: PKCE code_verifier is generated alongside code_challenge
// and stored in memory until the redirect comes back.
const tokens = await exchangeCode({
apiServer: "https://quire.io",
clientId: "your-cli-public-client-id",
code,
redirectUri: "http://127.0.0.1:54321/callback",
codeVerifier,
});
const client = new QuireClient({
tokens,
apiServer: "https://quire.io",
refreshTokens: (refreshToken) =>
refreshTokens({
apiServer: "https://quire.io",
clientId: "your-cli-public-client-id",
refreshToken,
}),
onTokenRefresh: async (newTokens) => {
await fs.writeFile(credPath, JSON.stringify(newTokens), { mode: 0o600 });
},
});QuireClient is silent by default. Pass a logger to surface API errors:
new QuireClient({
tokens,
apiServer: "https://quire.io",
logger: {
error: (msg, ctx) => console.error(msg, ctx),
info: (msg, ctx) => console.log(msg, ctx),
},
});The logger interface is { error, info, debug?, warn? } — any structural match works.
searchTasks (project), searchTasksInOrganization, and searchTasksInFolder all accept the same QuireTaskSearchParams shape. See the interface in src/client.ts for the full field list and per-field JSDoc; this section covers the grammar shared across many fields.
assignee, assignor, follower, createdBy, tag, priority, type, recurring all share the same grammar:
| Token | Meaning |
|---|---|
, |
AND |
| |
OR |
! |
NOT |
Values pass through verbatim — the server parses. Quote names containing spaces or special characters: tag: '"In Progress"'.
created, edited, archived, unarchived, toggled, start, due accept three operand styles:
| Style | Example | Notes |
|---|---|---|
| Keyword | due: "today" |
past, yesterday, today, tomorrow, upcoming, last7d, next7d, lastWeek, thisWeek, nextWeek — timezone is the caller's. |
op:value |
created: "ge:2026-01-01T00:00:00Z" |
Ops: ge, gt, le, lt, eq, ne, between, notBetween. Operand is ISO 8601; between / notBetween are inclusive on both ends. |
| Null | archived: "isNull" |
isNull / isNotNull, nullable fields only. |
start and due additionally accept a date-only operand (YYYY-MM-DD) that expands to a whole-day window in the caller's timezone.
Number / Money / Duration custom fields accept the same op:value grammar as date columns — ge: / gt: / le: / lt: / eq: / ne: / between:v1,v2 (inclusive) / notBetween:v1,v2 / isNull / isNotNull (case-insensitive). A bare value is exact match (equivalent to eq:). Duration operands use the same 8h / 30m shape as the modified interval.
await client.searchTasks(projectOid, {
customFields: {
Cost: "ge:100", // Money: ≥ 100
Score: "between:50,150", // Number: 50 ≤ x ≤ 150
Effort: "between:8h,40h", // Duration: 8h ≤ x ≤ 40h
},
});Other custom-field types are exact match — Email / Hyperlink also accept ~ / ~* prefix for regex; Text custom fields aren't searchable here (use the top-level text parameter for full-text search).
sublist and customFields are project-scope only — the org and folder endpoints reject them with Unsupported query parameter.
Pair limit (integer, or "no" for unlimited; free-plan cap is 30) with cursor. The last item of each page carries a cursor field; pass it as the next request's cursor (with the same limit and filters) to fetch the next page. The absence of cursor on the final item signals end of stream.
- A cursor reaches at most 1,000,000 rows deep; a deeper one is
400. Narrow the search with filters instead. - A
sublistsearch is never paged: it returns nocursor, andcursorcan't be combined withsublist(400). Pass alimitbig enough, or"no", to get every match.
Mixed filters — boolean grammar, date range, pagination cap:
const tasks = await client.searchTasks(projectOid, {
status: "active",
tag: '"In Progress",urgent', // (In Progress) AND urgent
assignee: `${me.oid}|${teammate.oid}`, // me OR teammate
due: "between:2026-05-01,2026-05-31",
priority: "high|urgent",
limit: 50,
});Recently modified (any edit, comment, status flip, etc.) — use the modified interval, not a date column:
const recent = await client.searchTasks(projectOid, {
modified: "7d", // "24h" / "30m" also valid; default is "7d"
status: "active",
limit: 50,
});modified defaults to "7d" if omitted; pass modified: false to search the full history. The response isn't sorted by modified time — sort client-side on QuireTask.modified if you need most-recent-first.
Follower fields share one value grammar everywhere they appear: a user OID, ID, or email, plus these special values:
| Value | Meaning |
|---|---|
"me" |
The authenticated user |
"app" |
The application itself (receives hook notifications) |
"app|team", "app|team|channel" |
App follower routed to a team / channel |
"app|/path" |
Appended to the registered hook URL — hook https://super.app/hooks/standard + "app|/soc1/33456/a7" → https://super.app/hooks/standard/soc1/33456/a7 |
"inherit" |
Tasks only. On add: pull in the parent task's followers. On remove: drop the ones inherited from the parent. |
Three field shapes, all optional and independent:
followers— replaces the entire list.addFollowers/removeFollowers— edit in place, leaving the rest alone.
| Method | followers |
addFollowers / removeFollowers |
|---|---|---|
createTask / createSubtask / createTaskRelative |
✅ | — (create has no list to diff) |
updateTask |
✅ | ✅ |
bulkCreateTasks / bulkCreateSubtasks / bulkUpdateTasks |
✅ | ✅ (update only — items are pass-through Record<string, unknown>) |
updateProject |
✅ | ✅ |
updateOrganization |
✅ | ✅ |
createChat / createDocument |
✅ | — |
updateChat / updateDocument |
✅ | ✅ |
// Follow a task as the current user, and route notifications to the app.
await client.updateTask(taskOid, { addFollowers: ["me", "app|eng|alerts"] });
// Stop following, but leave everyone else alone.
await client.updateTask(taskOid, { removeFollowers: ["me"] });
// Pin the list to exactly these two.
await client.updateTask(taskOid, { followers: [aliceOid, "bob@example.com"] });Notes:
- The creating user is added as a follower automatically by
createTask— passingfollowersat creation adds to that, it doesn't replace it. - Responses carry followers back as objects (
{ oid, id, name }[]) onQuireTask.followers,QuireProject.followers,QuireOrganization.followers,QuireChat.followers,QuireDocument.followers— not as the string refs you sent. - Documents are followable only when project-owned; sending follower fields for other owner types returns
400. searchTaskshas a separate read-sidefollowerfilter — see Search filters.
Sublists, documents, chat channels, insights, dashboards and reminders take an optional members list at creation, which decides who can see the record. It is a tri-state, and all three states come back explicitly so they can be told apart:
| Sent | Meaning | Returned as |
|---|---|---|
omitted, or null |
Every member of the owner (the default) | null |
[] |
The owner's admins only | [] |
["me", "bob@example.com"] |
Only those users | list of user objects |
const mine = await client.createSublist("project", projectOid, {
name: "Q3 planning",
members: ["me"], // only you
});Rules worth knowing before you send it:
- Create-only. There is no way to change
membersafterwards — recreate the record. - Every listed user must already be a member of the owner, and a non-empty list must include you. A non-member is rejected with
400rather than being dropped silently the way a task'sassigneesare. memberscannot be combined withpartner— that already shares the record with the whole project and the external team. The pair is400.- Responses return
membersas user objects ({ oid, id, name }), matchingfollowersandmutes. Before the Sep 21 2026 server release it was a list of OID strings.
Sublists, documents, chat channels, insights and dashboards also take private, which hides the record from guests and from the visitors of a public project or of a share link — the same switch a custom field has. It is a choice only while every member can see the record (members is null and there is no partner):
await client.createDocument("project", projectOid, { name: "Salaries", private: true });
await client.updateDocument(docOid, { private: false }); // guests can see it again- Defaults to
false, and unlikemembersit can be changed afterwards with theupdate*methods. - It is returned only while it is a choice. A record with
membersor apartneris always hidden from guests and visitors, soprivateis absent from its response,trueis accepted as a no-op, andfalseis400. - Reminders don't have it.
A custom field can limit who sees and edits its values. addProjectField / updateProjectField type these keys as QuireFieldAccess, and addInsightField / updateInsightField take the same:
await client.addProjectField(projectOid, {
name: "Salary",
type: "money",
viewMembers: ["me", "hr@example.com"], // only these users see the values
editMembers: ["hr@example.com"], // and only HR can change them
});
// Read-only in Quire, but still writable from your sync job
await client.updateProjectField(projectOid, "External ID", { locked: true });| Key | Sent | Returned on QuireFieldDefinition |
|---|---|---|
viewMembers |
user refs (OID, ID, email, "me"); [] = admins only; null lifts it |
user objects; absent = no restriction |
editMembers |
same; editing also requires viewing | user objects; absent = anyone who can edit the task |
locked |
true stops everyone editing the values in Quire, admins included |
true, or absent |
- An admin not named in
viewMembersstill sees the definition, but not the values: the API leaves the field out of the tasks it returns. Writing a field the caller can't edit is400, and a field the caller can't view is an unknownsearchTasksparameter (400). lockeddoesn't apply to the API — your writes still go through.editMembersdoes.private(hidden from guests and public visitors) is a choice only withoutviewMembers: with it the field is always hidden from them, soprivate: falseis400. ClearingviewMemberslater leaves the field private.- Like the other field flags,
privateandlockedcome back only whentrue.
A reminder notifies the people it names, either before a task's start/due date or at a time of its own. Unlike every other work endpoint, ownerType is always required — there is no implied project.
// Standalone reminder on a project
await client.createReminder("project", projectOid, {
when: "2027-01-15T09:00:00.000Z",
leads: [{ minutes: 30 }, { days: 1, at: "09:00" }],
});
// Personal reminder in your own inbox
await client.createReminder("project", "-", { when: "2027-01-15T09:00:00.000Z" });
// On a task that already has a start or due date — send `leads` alone
await client.createReminder("task", taskOid, { leads: [{ days: 1 }] });What it fires from depends on the task:
- Task with a start or due date → it fires from that date. Sending
whenorrecurrenceis rejected with400, because they would be ignored. - No task, or a task with neither date →
whenis required.
leads says when to notify ahead of the fire time, one notification each. Each entry names exactly one of minutes (an absolute offset) or days (calendar days, so it keeps its wall-clock time across a daylight-saving change); at ("HH:mm") is only valid with days and is read in the creating member's timezone. Omitting leads means a single notification at the fire time ([{ minutes: 0 }]). Max 30 leads, none negative or beyond 10,000 days.
Listing is by owner, and a project's list includes the reminders on its tasks — narrow it by passing "task":
await client.listReminders("project", projectOid); // project + its tasks'
await client.listReminders("task", taskOid); // just this task's
await client.listReminders("project", projectOid, { limit: 100 });
// Still to fire at a time of their own — e.g. to sync to a calendar
await client.listReminders("project", projectOid, {
timing: "standalone",
status: "active",
});Reminders are ordered by oid — stable, but not chronological, so sort client-side on the field you care about. With a numeric limit, the last item of a page carries a cursor when more remain.
Two filters narrow a list server-side; omitted, it is unchanged:
timing—"standalone"for the reminders firing at their ownwhen(no task, or a task with neither a start nor a due date),"anchored"for those firing from their task's date (theirwhenis null).status—"active"for only those with a notification still to come (not all past, not on a completed or archived task);"all"is the default. A page still holdslimitreminders while that many remain.
deleteReminder is permanent: a reminder is not moved to the trash and cannot be restored, so there is no undo-remove for it.
A comment — on a task or in a chat — carries its emoji reactions as emojis: each an emoji and the users who reacted with it, in the order the apps show them. The field is omitted when there are none.
await client.addCommentEmojis(commentOid, ["+1", "tada"]); // 👍 🎉
await client.removeCommentEmojis(commentOid, ["👍"]);
const { emojis } = await client.getComment(commentOid);
// [{ emoji: "🎉", users: [{ oid, id, name }] }]- Give an emoji as itself or by its GitHub / Slack shortcode, with or without colons and in any case —
"+1",":thumbsup:"and"👍"are the same. Names are the safer choice from scripts: no look-alike characters or missing variation selectors. Responses always return the emoji itself. The full list is in Quire's Emojis reference. - Add accepts only the 20 emojis Quire's reaction picker offers. One invalid emoji rejects the whole call with
400and nothing is written. Remove accepts any value, so a reaction with an emoji the picker no longer offers can still be removed. - Both are idempotent and resolve to nothing — read the comment back for the result. At most 20 distinct emojis per call.
- A reaction always belongs to the authorizing user, never the app. The comment's author is notified of each one added.
addCommentEmojison a missing or deleted comment is404;removeCommentEmojisis a silent no-op, like every delete.- The client doesn't validate emojis itself — the server's
400names the one it rejected. It only refuses an empty list.
| Class | Thrown when |
|---|---|
QuireAuthRevokedError |
The user's grant was revoked or expired past the refresh window. The caller should clear stored tokens and prompt the user to re-authorize. |
QuireTokenRefreshError |
A token refresh failed with a specific HTTP status. 4xx → grant is dead (the client converts these to QuireAuthRevokedError automatically); 5xx → transient, callers may retry. |
Error (with formatted message) |
Any other Quire API failure — body is run through formatQuireError so it stays compact and consumer-friendly. |
Since the Sep 21 2026 server release the HTTP status carries the category and the JSON body's code the specific cause. formatQuireError maps them to distinct messages, because the recovery differs:
Status + body code |
Meaning | What helps |
|---|---|---|
402 + 469 |
Plan limit or quota (tasks, custom fields, dashboard, insight, sublist, chat, doc), or a bulk call / nested task create over the plan's per-call cap | Upgrading, or for an oversized batch, splitting it. Retrying as is never does. |
402 + 470 |
Subscription expired | Resubscribing — no in-plan upgrade clears it |
403 + 471 |
Account temporarily blocked | Waiting, or contacting Quire |
429 |
The per-minute / per-hour API rate limit, and only this | Backing off; always carries Retry-After |
400 + 400 |
Validation (missing name, duplicate or reserved id) | Fixing the request |
Before that release, plan limits and expired subscriptions both came back as 429, which invited callers to back off and retry a condition that only upgrading could clear. The body-code checks are status-independent, so a client pointed at an older server still gets the right message.
npm install
npm test # offline / mocked unit tests (CI-default)
npm run typecheck # tsc --noEmit
npm run build # tsc → dist/
npm run gen-coverage # regenerate COVERAGE.md from src/client.tsA separate, opt-in suite under tests/live/ exercises
QuireClient against the real Quire API. It's gated on a configured env
file (see tests/live/README.md for setup) and never
runs during npm test.
npm run test:live:prepare # one-time OAuth bootstrap → writes tokens to ~/.config/quire/test-api.env
npm run test:live # run the full live-API suiteMIT © Potix Corporation