npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 | null

Every 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 through

You 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-client

Development

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 workspace

Domain language lives in CONTEXT.md, decisions in docs/adr/.