@ax-hub/sdk
v6.3.1
Published
agent-first Node.js SDK for AX Hub. Designed for Claude, Codex, and other coding agents.
Maintainers
Readme
@ax-hub/sdk
agent-first Node.js SDK for AX Hub. Designed for Claude, Codex, and other coding agents — one client, 7 bounded contexts, typed errors, async iterators for SSE streams, generated drift inventory, and no hidden Korean substring matching.
이 SDK를 개발/유지보수하려는 컨트리뷰터라면 → 내부 아키텍쳐·동작 원리·하네스·e2e 플로우를 설명한 온보딩 문서
docs/ARCHITECTURE.md부터 읽으세요. (이 README는 SDK 사용자용 API 가이드입니다.)
거버넌스(admin) surface 를 찾고 있다면 → tenants CRUD / authz / audit / identity-providers / category CUD 는
@ax-hub/sdk에서 분리되어 별도 패키지@ax-hub/admin-sdk로 이동했습니다 (1.0.0). 0.x 에서 올라온다면docs/MIGRATION-1.0.md의 매핑 표를 보세요.
I want to...
| Goal | Section |
|------|---------|
| make my first API call | Magic Moment |
| scope work to a tenant/app | Tenant Scoping |
| choose JWT vs PAT or OAuth | Authentication |
| access the app database | App Database |
| debug an error | Errors & Debugging |
| use admin/governance APIs | @ax-hub/admin-sdk |
| upgrade from 0.x | Migration & Upgrade |
Agent field guide from live QA (2026-06-08)
Use this section when an autonomous agent only has the README and must ship against AX Hub safely.
1. Runtime inputs
export AX_HUB_PAT="<short-lived PAT>"
export AX_HUB_TENANT_ID="cc1e58f1-8e46-4ac7-96c1-190c4cdd5b70" # test tenant
export AX_HUB_TENANT_SLUG="test"- PAT auth is
tokenType: 'pat'and is sent asX-Api-Key. - JWT auth is
tokenType: 'jwt'and is sent asAuthorization: Bearer. - Never log the token. Redact env dumps before saving QA artifacts.
2. Fastest safe live loop
- Create a timestamp-suffixed private app in tenant
test. - Set an env var, list env vars, then delete it and assert it is absent.
- Enable raw DB mode (
rawDb.enable), then introspect the physical DB withrawDb.tables. - Soft-delete the app and confirm it is gone with a follow-up
get.
3. Deletion semantics that prevent false positives
- App delete: prove it by a follow-up
getreturning404or410. - Deploy without git/bootstrap source can return a precondition-style 4xx. That proves error handling, not a deployment failure.
4. Production evidence already collected
- Node production mutation suite exercised app/env/comments/likes/table/columns/grants/OAuth/publication/deploy/git preconditions with exit
0. - A real app bootstrap/deploy wait succeeded in production: app
d31958ad-4a9b-4dcc-8951-64a1f3060c3d, deploymentd3a48ce3-0f9c-4bab-aa07-863c31c44460, final statussucceeded, followed by app permanent delete. - Go, Java, Kotlin, Python, and Ruby each hit 189 generated backend operation facades against the same production
testtenant with SDK exceptions0and backend 5xx0. - Go, Java, Kotlin, Python, and Ruby each passed the strict destructive DB loop above: 22 live steps, 17 assertions, 7 cleanup calls.
Magic Moment
import { AxHubClient } from '@ax-hub/sdk'
const sdk = new AxHubClient({
token: process.env.AX_HUB_PAT!,
tokenType: 'pat',
})
const acme = sdk.tenant('acme')
const app = await acme.apps.create({ slug: 'crm', name: 'CRM' })
// Enable a dedicated Postgres role; DATABASE_URL is injected on the next deploy.
await sdk.apps.rawDb.enable(app.id)
const tables = await sdk.apps.rawDb.tables(app.id)
console.log(app.slug, tables.length)5분 안에 첫 app ship 가능.
Resource Catalog
| Namespace | Methods |
|-----------|---------|
| sdk.apps | create, list, listAll, get, update, delete, listMine, resourcePresets, listEnvVars, setEnvVar, getEnvVar, deleteEnvVar |
| sdk.apps.publication | submit, list, unpublish (owner-scoped lifecycle) |
| sdk.apps.access | grant, revoke, me (self-grant; me() returns null on 404) |
| sdk.apps.likes | like, unlike, me (idempotent — backend returns liked/deleted booleans) |
| sdk.apps.comments | add, list, listAll, delete (1-500 char client-side validation) |
| sdk.apps.oauthClients | create (⚠ clientSecret surfaced ONCE), delete |
| sdk.apps.git | connect, installStart (GitHub App install flow) |
| sdk.apps.discover | catalog search facade |
| sdk.apps.templates | app template listing |
| sdk.deployments | create, list, listAll, get, cancel, rollback |
| sdk.identity | pat.*, oauth.get, oauth.revokeOwnGrant, systemOAuthClients.get, me (identity-provider governance moved to @ax-hub/admin-sdk) |
| sdk.tenants | get (read own tenant); CRUD + members/invitations/email-domains/icon moved to @ax-hub/admin-sdk |
| sdk.gateway | sessions (create / end), query.run (SQL via session), invoke (REST via session), me (connectors / resources / grants) |
| sdk.apps.rawDb | enable, disable, tables, tableRows (raw Postgres role + DB introspection) |
The committed generated route inventory currently tracks the pinned backend swagger snapshot; future backend-only BCs are surfaced by npm run route-inventory-diff.
sdk.apps.list vs sdk.apps.listMine
list()— returns apps in the resolved tenant (viadefaultTenantId/defaultTenantSlugorwithTenant). Tenant-scoped.listMine()— returns the caller's workspace apps (owned + apps they've been granted access to), regardless of tenant ownership. Useful for per-user "my dashboard" views. WrapsGET /me/apps/workspace.
1.0:
apps.create()requires a tenant context (defaultTenantIdorsdk.tenant(...)). Without one it throwsTenantIdRequiredError. See Migration.
sdk.apps.publication
sdk.apps.publication.*— owner-scoped: submit/list/unpublish own app. App ID is the input. Reviewer/admin approval flows are not part of the developer surface; operate them via the backend directly.
sdk.apps.oauthClients.create — raw secret surfaced once
const c = await sdk.apps.oauthClients.create('app_abc', {
name: 'web',
redirectUris: ['https://myapp.com/cb'],
scopes: ['read', 'write'],
})
storeSecret(c.clientSecret) // <-- this is your ONLY chance. Backend keeps only a hash.
// Subsequent list/get methods will NOT include the secret.Lose clientSecret? Delete the client and create a new one.
Tenant Scoping
Prefer scoped clients in examples:
const acme = sdk.tenant('acme')
const app = await acme.apps.create({ slug: 'crm', name: 'CRM' })
await sdk.apps.rawDb.tables(app.id)Flat/root APIs remain available for non-tenant routes (sdk.identity.me, sdk.identity.pat.*, sdk.identity.systemOAuthClients.get) and for backwards compatibility. There is no deprecation warning for flat calls.
Authentication
PAT (recommended for agents) — single token, immutable, sent as X-Api-Key on data-ring requests.
const sdk = new AxHubClient({ baseUrl, token, tokenType: 'pat' })JWT with refresh — caller-provided refresh callback; concurrent 401s share a single in-flight refresh. The SDK does not issue tokens: onRefresh returns a fresh access token minted by your own auth flow.
const sdk = new AxHubClient({
baseUrl,
token: jwt,
tokenType: 'jwt',
onRefresh: async () => {
const res = await fetch(`${baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken }),
})
const body = (await res.json()) as { access_token: string }
return body.access_token
},
})OAuth Two Worlds
sdk.apps.oauthClients— an app acts as IdP for its own external users.sdk.identity.systemOAuthClients.get— fetch a system OAuth client by id (the only global OAuth-client route). Creating one is app-scoped viasdk.apps.oauthClients.create.
App Database
DB access is via sdk.apps.rawDb.*, which enables a dedicated Postgres role and introspects the physical database. (The managed dynamic-table API sdk.apps.tables.* was removed — see CHANGELOG; use raw DB mode below.)
Raw DB mode
await sdk.apps.rawDb.enable(appId)
const tables = await sdk.apps.rawDb.tables(appId)
const rows = await sdk.apps.rawDb.tableRows(appId, 'orders', { perPage: 100 })
void [tables, rows]rawDb.enable issues a dedicated Postgres role; DATABASE_URL is injected on the next deploy (the connection string is never returned). Use tables / tableRows for information_schema introspection of the physical DB.
Errors & Debugging
Error Types
All /api/v1/* 4xx/5xx responses become typed AxHubError subclasses. error.category (9 enum from backend spec) drives base class, error.code selects specific subclass.
| Class | Status | retryable | Hint |
|-------|--------|-------------|------|
| UnauthenticatedError (+ TokenMissingError, TokenExpiredError, TokenInvalidError) | 401 | true (re-auth) | refresh token |
| PermissionDeniedError (+ NotAdminError, ForbiddenError, NotMemberError, NotAllowedError) | 403 | false | request grant |
| NotFoundError (+ PermanentlyDeletedError, InvitationExpiredError) | 404 / 410 | false | abort |
| ConflictError (+ SlugTakenError, AlreadyMemberError, AlreadyDeletedError, AlreadyRevokedError, AlreadySettledError, AlreadyAccessedError, PendingExistsError, InvalidStateTransitionError, SchemaNameTakenError, DomainTakenError, NotDeletedError, LastAdminError, DuplicateError) | 409 | false | try different value |
| ValidationError (+ InvalidValueError, RequiredError, EmptyError, BadRequestError) | 400 / 422 | false | fix fields[] |
| PreconditionFailedError | 412 | false | reconcile state |
| RateLimitedError | 429 | true | sleep retry.afterMs |
| InternalServerError | 500 | false | escalate to human |
| UnavailableError (+ AppUnavailableError) | 502 / 503 / 504 | true | exponential backoff |
| NetworkError, TimeoutError, DecodeError, AbortError | — | varies | — |
| OAuthError (+ specific RFC 6749 codes incl. InvalidTargetError) | — | varies (per code) | — |
try {
await sdk.apps.create({ slug: 'taken', name: 'X' })
} catch (e) {
if (e instanceof SlugTakenError) {
// retry with different slug; e.fields[0].name === 'slug'
}
}Gateway (session-scoped connector access)
sdk.tenant(slug).gateway is member-facing — open a session against a connector you hold an active grant on, then run SQL or proxy REST calls through it:
me.connectors()/me.connectorResources(id)/me.grants()— discover the connectors, resource trees, and grants available to you.sessions.create({ connectorId })/sessions.end(id)— open / close an 8h session (snapshots your grant's preset).query.run({ sessionId, sql, params })— parameterized SQL read through a session.invoke({ sessionId, method, path, body })— proxy a REST call through a session (REST-API connectors).
Admin-only connector governance is not in the SDK — manage those in the AX Hub console.
| Situation | What you get |
|-----------|--------------|
| sessions.create(...) without an active grant on the connector | NotFoundError thrown — no grant means the connector is not exposed (strict zero-trust). |
| query.run(...) / invoke(...) policy deny | PermissionDeniedError thrown (403) — the session preset forbids the action. There is no in-band allowed flag; catch the typed error. |
| query.run(...) / invoke(...) on an expired session | UnauthenticatedError thrown — sessions live ~8h; open a fresh one. |
const gw = sdk.tenant(slug).gateway
const session = await gw.sessions.create({ connectorId: 'con_1' })
try {
const { rows } = await gw.query.run({
sessionId: session.id,
sql: 'SELECT id, name FROM employees LIMIT ?', params: [20],
})
renderRows(rows)
} finally {
await gw.sessions.end(session.id)
}Webhook Handling
import { verifyWebhook } from '@ax-hub/sdk'
const result = verifyWebhook({
rawBody,
secret: process.env.AX_HUB_WEBHOOK_SECRET!,
signature: headers.get('x-ax-hub-signature')!,
timestamp: headers.get('x-ax-hub-timestamp') ?? undefined,
})Verification uses HMAC SHA-256, timing-safe comparison, timestamp tolerance, and optional replay cache.
Idempotency
HttpClient supports per-call idempotencyKey and generated Idempotency-Key on explicitly idempotent SDK calls. Empty keys fail fast with typed validation.
Codegen workflow
Generated inventory is committed under codegen/generated/.
npm run generate
npm run extract-codes
npm run route-inventory-diffCI fails if swagger route inventory and generated files drift.
Migration & Upgrade
0.x → 1.0.0 is a hard cut (no prior deprecation). Two breaking changes:
- admin governance 36 op moved to the new
@ax-hub/admin-sdkpackage. apps.create()requires a tenant context (defaultTenantIdorsdk.tenant(...)).
See docs/MIGRATION-1.0.md for the full old→new mapping table and CHANGELOG.md [1.0.0] for the complete change list.
Concepts
- Tenant scoping.
defaultTenantId/defaultTenantSlugon constructor,client.withTenant(slug)for per-call switching,TenantSlugRequiredError/TenantIdRequiredErrorwhen ambiguous/missing. - Pagination.
list({ pageSize, cursor })for single page.listAll({ pageSize })for async iterator that yields{type:'item', value:T}or{type:'drift', addedSince}when the backend's total grows mid-iteration. - Rate limiting. Default strategy
'sleep'— SDK honorsRetry-Afterand silently retries. UserateLimitStrategy: 'throw'to surfaceRateLimitedError(retry.afterMs)to caller. - Request correlation. SDK auto-generates
X-Request-Id(ULID) on every request. Backend echoes it OR replaces with its ownreq_xxxprefix if absent.AxHubError.requestIdalways present. - Token redaction. Authorization / X-Api-Key / Cookie are always replaced with
***REDACTED***in debug logs andError.toJSON()output. - Debug mode.
new AxHubClient({ debug: true, logger: pino() })— opt-in structured request/response logging. Default off. - Language. Backend
error.messageis Korean (user-facing) by design.error.codeanderror.categoryare machine-readable (snake_case English) and stable across translations. Agents should branch oncode/category; humans see the Koreanmessage.
Branded ID types (optional)
For callers wanting compile-time guards against mixing IDs (e.g., passing tenantId where appId is expected):
import { type AppId, type DeploymentId, asAppId } from '@ax-hub/sdk'
const appId: AppId = asAppId('app_abc')
const depId: DeploymentId = asDeploymentId('dep_xyz')
await sdk.deployments.create(appId) // OK
// await sdk.deployments.create(depId) // type error — DeploymentId ≠ AppIdThe SDK's own methods still accept plain string — branded types are opt-in for caller code.
Performance
SDK overhead (excluding network + backend time), measured via npm run bench against an in-memory fake fetch:
| Operation | p99 |
|-----------|-----|
| apps.create happy path | < 0.08ms |
| apps.create 409 conflict (error dispatch) | < 0.08ms |
| Error dispatch alone (wrapped envelope → typed subclass) | < 0.005ms |
| SSE frame parser (100 frames / chunk) | < 0.07ms |
Target was < 10ms p99 — 100x+ headroom in every path.
Backend dependency
Pinned against backend main (189 routes, 43 error codes). Re-generate types via npm run generate + npm run extract-codes after backend swagger updates.
License
Apache-2.0. See LICENSE.
