@ax-hub/sdk
v3.0.0
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, 8 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 |
| query dynamic tables | Dynamic Data + Query DSL |
| 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.
- Create a table with
owner_id,title,status, and optional metadata columns. - Add a column, inspect the table, then delete the column and inspect again.
- Add a table grant, list grants, revoke the grant, then assert the same grant id has
revokedAt. - Insert a row, get it by id, patch it, list it with a filter, count it, browse admin rows, then delete it.
- Assert row get after delete returns
404or410. - Delete the table and assert inspect after delete returns
404or410. - Soft-delete the app, then permanent-delete the app.
3. Deletion semantics that prevent false positives
- Row delete: prove it by a follow-up
getreturning404or410. - Table delete: prove it by a follow-up
inspectreturning404or410. - Table grant delete: AX Hub currently soft-revokes.
listGrantscan still return the grant, butrevokedAtmust be set. Do not assert hard disappearance. - 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/data/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, defineSchema, where } from '@ax-hub/sdk'
const sdk = new AxHubClient({
token: process.env.AX_HUB_PAT!,
tokenType: 'pat',
})
const crm = sdk.tenant('acme').app('crm')
const Orders = defineSchema({
table: 'orders',
columns: {
id: 'uuid',
status: { type: 'enum', values: ['paid', 'pending'] as const },
total: 'number',
},
})
const app = await sdk.tenant('acme').apps.create({ slug: 'crm', name: 'CRM' })
const paid = await crm.data.table(Orders).list({
where: where(Orders.cols.status).eq('paid'),
})
console.log(app.slug, paid.total)5분 안에 첫 app ship 가능.
Resource Catalog
| Namespace | Methods |
|-----------|---------|
| sdk.apps | create, list, listAll, get, update, delete, permanent, listMine, signIconUploadURL, signIconDarkUploadURL, 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.categories | tenant category read (list, get); CUD moved to @ax-hub/admin-sdk |
| sdk.apps.discover | catalog search facade |
| sdk.apps.templates | app template listing |
| sdk.apps.tables | list, create, delete, addColumn, dropColumn, listGrants, addGrant, revokeGrant (schema admin) |
| sdk.publicationRequests | get, approve, reject, listPending (reviewer/admin namespace — separate from owner-scoped sdk.apps.publication) |
| sdk.deployments | create, list, listAll, get, cancel, rollback |
| sdk.identity | pat.*, oauth.*, oidc.*, deviceCode.*, 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.data | /data/{tenantSlug}/{appSlug}/{table} CRUD + bulk + typed DSL |
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 vs sdk.publicationRequests
sdk.apps.publication.*— owner-scoped: submit/list/unpublish own app. App ID is the input.sdk.publicationRequests.*— reviewer/admin-scoped: get/approve/reject any publication request, list all pending. Publication request ID is the input. Requirespublication_reviewer(approve/reject) or platform admin (listPending) roles.
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.tables.inspect(app.id, 'orders')
await acme.app('crm').data.table('orders').list()Flat/root APIs remain available for non-tenant routes (sdk.identity.oauth.exchangeCode, sdk.identity.oidc.jwks, sdk.identity.me) 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.
const refreshClient = new AxHubClient({ baseUrl, token: jwt, tokenType: 'jwt' })
const sdk = new AxHubClient({
baseUrl,
token: jwt,
tokenType: 'jwt',
onRefresh: async () => (await refreshClient.identity.oauth.refreshTokens({ refreshToken })).accessToken,
})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.
Dynamic Data + Query DSL
apps.tables.* owns DDL. data.discover() and data.table(schema) own runtime CRUD with PAT/data-ring auth.
Quick start — discover first
Agents can start without hand-writing a schema:
const orders = await sdk.tenant('acme').app('crm').data.discover<{
id: string
status: 'paid' | 'pending'
total: number
}>('orders')
await orders.insert({ status: 'paid', total: 99 })discover(table, { fresh, ttlMs }) introspects the table, caches schema per client, de-duplicates concurrent fetches, and returns the same DataTableClient surface as data.table(schema).
Recommended path:
| User | API |
|------|-----|
| Agent / CI | data.discover('orders') — runtime schema, no handwritten shape |
| App code | data.table(Orders) — explicit schema kept near the feature |
| Avoid | data.table('orders') for writes — no local validation/projection checks |
Explicit schema + DSL
const Orders = defineSchema({
table: 'orders',
columns: { status: { type: 'enum', values: ['paid', 'pending'] as const }, total: 'number' },
})
const page = await sdk.tenant('acme').app('crm').data.table(Orders).list({
where: and(where(Orders.cols.status).eq('paid'), where(Orders.cols.total).gt(100)),
})like.contains/startsWith/endsWith automatically escapes %, _, and \\. Use raw() only when you intentionally own SQL-like syntax.
Zod-compatible validation
defineSchema(..., { validate }) accepts a Zod-compatible object with safeParse(). Invalid inserts/updates fail before network with ValidationError; invalid validator objects fail with ConfigurationError.
const OrdersChecked = defineSchema({
table: 'orders',
columns: { total: 'number' },
}, {
validate: {
safeParse: (row: unknown) => (row as { total?: number }).total! > 0
? { success: true }
: { success: false, error: { issues: [{ path: ['total'], code: 'too_small' }] } },
},
})
await sdk.tenant('acme').app('crm').data.table(OrdersChecked).insert({ total: 99 })Projection
Use select to request and type-narrow only the columns you need. The SDK sends backend _select=id,total, validates unknown columns when a schema is available, and returns Pick<Row, K>.
const OrdersProjection = defineSchema({ table: 'orders', columns: { id: 'uuid', total: 'number' } })
const ordersProjection = sdk.tenant('acme').app('crm').data.table(OrdersProjection)
const minimal = await ordersProjection.list({ select: ['id', 'total'] as const })
minimal.items[0].total // numberOffset pagination
Backend-main data routes are offset/page based. Use pageSize with the numeric string cursor returned by the previous page. after, before, direction, and v1:/v2: keyset tokens are rejected with LegacyCursorError because the backend does not support keyset cursors.
total is optional: use items and nextCursor for iteration, or call count() when you need a backend count for the same pushable where filter.
const OrdersCursor = defineSchema({ table: 'orders', columns: { id: 'uuid' } })
const ordersCursor = sdk.tenant('acme').app('crm').data.table(OrdersCursor)
const first = await ordersCursor.list({ pageSize: 50 })
const next = first.nextCursor
? await ordersCursor.list({ cursor: first.nextCursor, pageSize: 50 })
: null
void nextMock mode
Use mock mode for backend-free unit tests, examples, and agent CI. Fixtures are isolated per client and still exercise where/projection/offset pagination/Zod behavior. Keep fixture sets small enough for in-memory tests (recommended ≤10K rows per process); use the real backend for load/perf scenarios.
const sdk = new AxHubClient({
mode: 'mock',
fixtures: { 'acme/crm/orders': [{ id: 'o1', total: 99, status: 'paid' }] },
})
const paid = await sdk.tenant('acme').app('crm').data.discover('orders')Production mock mode throws MockInProductionError unless AX_HUB_ALLOW_MOCK_IN_PROD is set to a truthy value. The SDK normalizes both env vars: NODE_ENV is matched case- and whitespace-insensitive against 'production', and the opt-in accepts any of '1' / 'true' / 'yes' / 'on' (case- and whitespace-insensitive). Mock parity also throws NotFoundError on get/update/delete of missing rows and ConflictError on duplicate insert — drop-in behaviour matches the real backend.
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.
