@enbox/api
The high-level TypeScript SDK for decentralised identity and data — typed protocols, records CRUD, real-time subscriptions, and anonymous queries.
@enbox/api is the main package developers use to interact with the Enbox network. It provides a type-safe, protocol-scoped API for creating, reading, updating, deleting, and subscribing to records stored in Decentralised Web Nodes (DWNs).
Installation
bun add @enbox/apiCore concepts
Enbox
The Enbox class is the entry point. You create an instance with Enbox.connect():
import { Enbox } from '@enbox/api';
const enbox = Enbox.connect({ agent, connectedDid: did });The agent manages signing keys, DWN sync, and identity storage. The connectedDid is the user's DID. If you use @enbox/auth for authentication, you can pass the session directly:
const enbox = Enbox.connect({ session });The Enbox instance exposes:
| Property / Method | Description |
|---|---|
enbox.using(protocol) | Returns a TypedEnbox scoped to a protocol |
enbox.did | DID API — create and resolve DIDs |
enbox.vc | Verifiable Credentials API |
enbox.agent | The underlying EnboxAgent |
enbox.disconnect() | Stops sync and clears cached instances |
There is no public dwn property on the Enbox instance. All DWN access goes through enbox.using(protocol).
defineProtocol
defineProtocol() creates a typed protocol definition that carries compile-time type metadata:
import { defineProtocol } from '@enbox/api';
interface TaskData {
title: string;
done: boolean;
}
const TaskProtocol = defineProtocol({
protocol: 'https://example.com/tasks',
published: true,
types: {
task: {
schema: 'https://example.com/schemas/task',
dataFormats: ['application/json'],
},
},
structure: {
task: {},
},
}, {} as {
task: TaskData;
});The second argument is a phantom schema map — it exists only at compile time to map protocol type names to TypeScript interfaces. The runtime value ({}) is ignored.
TypedEnbox
TypedEnbox is the primary developer interface. You get one by calling enbox.using(protocol):
const tasks = enbox.using(TaskProtocol);Instances are cached by protocol URI — calling using() multiple times with the same protocol returns the same instance.
TypedEnbox auto-configures the protocol on the first record operation — it queries the local DWN for an existing installation and installs one if needed. You can start using records immediately without any setup step.
If you need explicit control (for example, to check the status code or to pre-install before any reads), call configure() manually:
const { status } = await tasks.configure();
// status.code: 200 (already installed) or 202 (newly installed)TypedEnbox.records
All record methods auto-inject protocol, protocolPath, and schema — you only provide the path and your data:
create
const { status, record } = await tasks.records.create('task', {
data: { title: 'Buy milk', done: false },
});
// record is TypedRecord<TaskData>Create options:
| Field | Type | Description |
|---|---|---|
data | T (type-checked) | The record payload |
parentContextId | string? | Link to a parent record's contextId |
published | boolean? | Whether the record is publicly readable |
recipient | string? | DID of the intended recipient |
protocolRole | string? | Role for permission-scoped writes |
tags | Record<string, ...>? | Indexed key-value metadata |
store | boolean? | Persist locally (default true) |
encryption | boolean? | Force or skip encryption |
dataFormat | string? | MIME type override |
query
const { records, cursor } = await tasks.records.query('task');
for (const r of records) {
const data = await r.data.json(); // TaskData
console.log(data.title, data.done);
}Query with filters, sorting, and pagination:
import { DateSort } from '@enbox/dwn-sdk-js';
const { records, cursor } = await tasks.records.query('task', {
filter: { tags: { done: false } },
dateSort: DateSort.CreatedDescending,
pagination: { limit: 25 },
});
// Fetch next page
if (cursor) {
const { records: next } = await tasks.records.query('task', {
pagination: { limit: 25, cursor },
});
}read
Read a specific record by ID:
const { record } = await tasks.records.read('task', {
filter: { recordId: taskId },
});
const data = await record.data.json(); // TaskDataRead from a remote DWN:
const { record } = await tasks.records.read('task', {
from: 'did:dht:alice...',
filter: { recordId: taskId },
});delete
Delete via the TypedEnbox API:
const { status } = await tasks.records.delete('task', {
recordId: record.id,
});Or delete via the record instance:
await record.delete();subscribe
Subscribe to real-time changes:
const { liveQuery } = await tasks.records.subscribe('task');See the LiveQuery section below for event handling.
TypedRecord
Every record returned by TypedEnbox is wrapped in TypedRecord<T>, which provides type-safe data access and lifecycle methods.
Data accessors
const task = await record.data.json(); // Promise<TaskData>
const raw = await record.data.text(); // Promise<string>
const blob = await record.data.blob(); // Promise<Blob>
const bytes = await record.data.bytes(); // Promise<Uint8Array>
const stream = await record.data.stream(); // Promise<ReadableStream>Data is cached in memory up to 10 MB — subsequent calls to any accessor return the cached value without re-reading from the DWN.
Metadata
| Property | Type | Description |
|---|---|---|
record.id | string | Unique, stable record ID |
record.contextId | string? | Hierarchical grouping ID |
record.dateCreated | string | ISO 8601 creation timestamp (immutable) |
record.timestamp | string | ISO 8601 timestamp of last mutation |
record.author | string | DID of the current message signer |
record.creator | string | DID of the original author |
record.protocol | string? | Protocol URI |
record.protocolPath | string? | Path within the protocol structure |
record.schema | string? | Schema URI from the protocol type |
record.dataFormat | string? | MIME type of the payload |
record.dataSize | number? | Payload size in bytes |
record.tags | object? | Indexed key-value metadata |
record.published | boolean? | Whether publicly readable |
record.deleted | boolean | Whether the record has been deleted |
record.parentId | string? | Parent record ID |
record.recipient | string? | Recipient DID |
Mutation methods
update
const { status, record: updated } = await record.update({
data: { title: 'Buy oat milk', done: false }, // Partial<TaskData>
tags: { priority: 'high' },
});The data field accepts Partial<T> — only supply the fields you want to change. Both the returned record and the original instance reflect the updated state.
delete
const { status } = await record.delete();
// status.code: 202 on successDelete with options:
await record.delete({ prune: true }); // also delete child recordsLifecycle methods
| Method | Description |
|---|---|
record.send(target?) | Push the record to a remote DWN. Defaults to your own remote DWN. |
record.store(importRecord?) | Persist to local DWN (for records created with store: false) |
record.import(store?) | Re-sign under your identity and optionally store (for importing others' records) |
record.rawRecord | Escape hatch to the underlying untyped Record |
LiveQuery
LiveQuery (and its typed wrapper TypedLiveQuery<T>) provides an initial snapshot plus a real-time stream of deduplicated change events.
const { liveQuery } = await tasks.records.subscribe('task');
// Initial snapshot
for (const record of liveQuery.records) {
console.log(await record.data.json());
}
// Real-time events
liveQuery.on('create', (record) => {
console.log('New task:', record.id);
});
liveQuery.on('update', (record) => {
console.log('Updated:', record.id);
});
liveQuery.on('delete', (record) => {
console.log('Deleted:', record.id);
});
// Catch-all
liveQuery.on('change', (change) => {
console.log(change.type, change.record.id);
});Events
| Event | Callback argument | Description |
|---|---|---|
create | TypedRecord<T> | A new record was written |
update | TypedRecord<T> | An existing record was updated |
delete | TypedRecord<T> | A record was deleted |
change | { type, record } | Catch-all for any of the above |
disconnected | (none) | Transport connection lost |
reconnecting | { attempt: number } | Reconnection attempt in progress |
reconnected | (none) | Connection restored |
eose | (none) | End of stored events — catch-up complete, events are now live |
The .on() method returns an unsubscribe function:
const off = liveQuery.on('create', handler);
off(); // stop listeningClose the subscription when done:
await liveQuery.close();Connection lifecycle
liveQuery.isConnected reflects the current transport state. Use the lifecycle events to show connection status in your UI:
liveQuery.on('disconnected', () => showOfflineBanner());
liveQuery.on('reconnecting', ({ attempt }) => {
console.log(`Reconnection attempt ${attempt}...`);
});
liveQuery.on('reconnected', () => hideOfflineBanner());Anonymous queries
Enbox.anonymous() creates a lightweight, read-only instance for querying published records without authentication:
const { dwn } = Enbox.anonymous();
const { records } = await dwn.records.query({
from: 'did:dht:alice...',
filter: {
protocol: 'https://social.example/posts',
protocolPath: 'post',
},
});
for (const record of records) {
console.log(record.id, await record.data.text());
}No identity, vault, password, or signing keys are required. Only unsigned (anonymous) DWN messages are supported — you can query and read, but not write.
Hierarchical records
Protocols can define nested structures. Use parentContextId to create child records:
const NotebookProtocol = defineProtocol({
protocol: 'https://example.com/notebooks',
published: true,
types: {
notebook: {
schema: 'https://example.com/schemas/notebook',
dataFormats: ['application/json'],
},
page: {
schema: 'https://example.com/schemas/page',
dataFormats: ['application/json'],
},
},
structure: {
notebook: {
page: {},
},
},
}, {} as {
notebook: { name: string };
page: { title: string; body: string };
});
const notebooks = enbox.using(NotebookProtocol);
// Create a parent
const { record: notebook } = await notebooks.records.create('notebook', {
data: { name: 'Travel Notes' },
});
// Create a child under the parent
const { record: page } = await notebooks.records.create('notebook/page', {
data: { title: 'Day 1', body: 'Arrived in Tokyo...' },
parentContextId: notebook.contextId,
});
// Query children of a specific parent
const { records: pages } = await notebooks.records.query('notebook/page', {
filter: { parentId: notebook.contextId },
});Disconnecting
When your application shuts down or the user signs out, call disconnect() to stop background sync and release resources:
await enbox.disconnect();After calling disconnect(), the Enbox instance should not be reused. Create a new one with Enbox.connect() for the next session.