@duyquangnvx/ztask-api-client
v0.2.1
Published
TypeScript client for the ZTask project management API
Downloads
242
Readme
ztask-api-client
A TypeScript client for the ZTask project-management API, published as
@duyquangnvx/ztask-api-client. It wraps 90 of the API's 260 operations across
15 namespaces, normalises the three list envelopes the API returns into one
Page<T>, and validates responses against schemas built from live samples.
It stops at a clean HTTP surface and takes no opinion on how an agent should use it.
import { createZTaskClient, ZTASK_BASE_URL } from '@duyquangnvx/ztask-api-client'
const client = createZTaskClient({
apiKey: process.env.ZTASK_PAT,
baseUrl: ZTASK_BASE_URL.staging,
})
const page = await client.issues.list(projectId, { limit: 20 })baseUrl is required on purpose: a default would let a misconfigured process
reach live ZTask while believing it was on staging.
Identity
The PAT is the whole identity, and nothing here acts on anyone else's behalf.
Every /me/… route answers for the token's owner.
The API appears to offer otherwise and does not. Its spec declares
x-user-email required on 21 operations, and the generated types in this
package repeat that claim, because they are generated from it. The server
ignores the header — every value, every route, including the ones that declare
it (ADR-0002). There is no header,
and no client option, that changes who a call speaks for.
So a process serving several people needs a PAT for each of them. Give it one shared PAT and it acts as itself, silently and with a 200 — which is how a bot asked to tick someone's task ticks its own list and reports success.
Failures
Every call either returns a value or throws. Five things can come out; the reasoning behind the split is in ADR-0006.
| Thrown | Means | isZTaskError |
| --- | --- | --- |
| ZTaskHttpError | the server answered 4xx or 5xx | true |
| ZTaskTransportError | no response arrived — timeout, DNS, reset | true |
| ZTaskValidationError | the response did not match our schema | true |
| ZTaskRequestError | the body was refused before anything was sent | true |
| your abort() reason | you cancelled the call | false |
reconcile adds a sixth, ZTaskUncertainWriteError, described below.
import { isZTaskError, ZTaskHttpError } from '@duyquangnvx/ztask-api-client'
try {
await client.labels.create(projectId, { name: 'live-ops' })
} catch (error) {
if (error instanceof ZTaskHttpError && error.status === 409) {
// A label with that name already exists.
}
if (!isZTaskError(error)) throw error
}Branch on status plus the call you made
There is no isNotFound, no isConflict and no error code, because the API
supplies nothing finer than the status. Duplicating a label, a state or an issue
type all produce byte-identical 409s, so what collided is knowable only from the
request you sent — which error.request carries as { method, url }.
message is for showing to a human, never for matching on. A 409 is written in
Vietnamese while every other status is written in English, and no language header
changes that.
Absence is not uniformly a 404 either: projects.get answers 403 for an id
that does not exist, because the API declines to say whether a resource you
cannot see is missing. Issues, sprints and milestones answer a plain 404.
null means "not found", not "failed"
Two lookups take a key a person typed, so absence is an answer rather than an exception:
const project = await client.projects.getByKey('SKY') // Project | null
const issue = await client.issues.getBySequence(id, 42) // Issue | nullEvery get(id) throws instead. An id that came from this client and no longer
resolves means you are holding a stale one.
Retries
GET is retried on transport failures, 429 and 5xx — three attempts, exponential
backoff with full jitter. Mutations are never retried: the API has no idempotency
key, so a retried POST could silently duplicate a user's data
(ADR-0005). Both are configurable through
retry, and isRetryable(error) is exported if you would rather decide
yourself.
Timeouts and cancellation
One attempt is bounded at 15 seconds by default, so a worst-case GET is three
attempts plus backoff. A timeout arrives as ZTaskTransportError with
timedOut: true, and is retried.
const controller = new AbortController()
const client = createZTaskClient({
apiKey,
baseUrl: ZTASK_BASE_URL.staging,
timeoutMs: 15_000,
signal: controller.signal,
})
controller.abort(new Error('user pressed stop'))Cancelling rethrows the reason you passed to abort(), untouched — you already
know why you stopped, so the client does not dress it up as a failure of its own.
Enums that grow
ZTask's enums are a list, not a contract. When it adds a priority this client does not know, the value arrives as itself rather than failing the response — a single unfamiliar issue must not cost you the page it was on, or turn a write that succeeded into one nobody can account for. The types say so:
type Priority = 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' | 'NONE' | (string & {})Autocomplete still offers the five; an exhaustive switch no longer compiles
without a fallback, which is the point. Only the read side is open — send a
priority the API does not define and the client refuses before the request
leaves, because several endpoints answer 200 and quietly store null.
Wire onEnumDrift to hear about it. Without it nothing is lost — the value is
in the data either way — but nothing is announced either.
const client = createZTaskClient({
apiKey,
baseUrl: ZTASK_BASE_URL.staging,
onEnumDrift: ({ enum: name, value, request }) =>
logger.warn({ name, value, request }, 'ZTask returned an enum value we do not list'),
})See ADR-0011.
A transport failure on a mutation means unknown, not did not happen. When a POST, PATCH, PUT or DELETE times out, the server may well have applied it, and there is no request id to reconcile against. Read the state back before retrying by hand. An agent that assumes the write was lost will create the second issue and report having created one.
reconcile does that reading for you
Rather than leaving every caller to build it, reconcile runs a mutation and
reads the state back when the outcome is in doubt — writing again only once the
read proves nothing landed.
import { reconcile } from '@duyquangnvx/ztask-api-client'
const result = await reconcile({
write: () => client.issues.create(projectId, { title }),
find: async () => {
const page = await client.issues.list(projectId, { limit: 50 })
return page.items.find((issue) => issue.title === title) ?? null
},
})
result.outcome // 'written' — it went through
// 'recovered' — an earlier attempt had already gone throughYou supply find because only you know what makes your write recognisable;
there is no idempotency key to do it for you. A find that reports absence for
something that exists is the one thing that defeats this, so when in doubt widen
the search rather than narrow it.
When even the read-back cannot settle it, you get ZTaskUncertainWriteError.
It is deliberately not retryable — it means nothing in the process knows what
happened, and the next step belongs to a person. The reasoning is in
ADR-0009.
The bundled skill
The package ships an agent skill at skill/ztask-api-client, covering the few
things that sit above any single method: that the PAT is the identity, how to
choose a find for reconcile, which of the two type sources to trust, and
staging. Claude Code scans .claude/skills only, so link it in rather than
copying it — a link follows the version you have installed, a copy rots:
mkdir -p .claude/skills
ln -s ../../node_modules/@duyquangnvx/ztask-api-client/skill/ztask-api-client \
.claude/skills/ztask-api-clientDevelopment
pnpm test # offline suite, gates every commit
pnpm typecheck
pnpm contract:check # live diff against staging; never gates a merge
pnpm write:audit # exercises every create/update against a throwaway workspaceDomain language lives in CONTEXT.md, decisions in
docs/adr/.
