@enbox/api
Research Preview -- Enbox is under active development. APIs may change without notice.

The high-level SDK for building decentralized applications with protocol-first data management. This is the main consumer-facing package in the Enbox ecosystem -- most applications only need @enbox/api.
Table of Contents
Installation
bun add @enbox/api
Quick Start
import { defineProtocol, Web5 } from '@enbox/api';
const { web5, did: myDid } = await Web5.connect();
const NotesProtocol = defineProtocol({
protocol : 'https://example.com/notes',
published : true,
types : {
note: {
schema : 'https://example.com/schemas/note',
dataFormats : ['application/json'],
},
},
structure: {
note: {},
},
} as const, {} as {
note: { title: string; body: string };
});
const notes = web5.using(NotesProtocol);
await notes.configure();
const { record } = await notes.records.create('note', {
data: { title: 'Hello', body: 'World' },
});
const { records } = await notes.records.query('note');
for (const r of records) {
const note = await r.data.json();
console.log(r.id, note.title);
}
await record.send(myDid);
Core Concepts
Web5.connect(options?)
Connects to a local identity agent. On first launch it creates an identity vault, generates a did:dht DID, and starts the sync engine. On subsequent launches it unlocks the existing vault.
const { web5, did, recoveryPhrase } = await Web5.connect({
password: 'user-chosen-password',
});
Options (all optional):
password | string | Password to protect the local identity vault. Defaults to an insecure static value -- always set this in production. |
recoveryPhrase | string | 12-word BIP-39 phrase for vault recovery. Generated automatically on first launch if not provided. |
sync | string | Sync interval (e.g. '2m', '30s') or 'off' to disable. Default: '2m'. |
didCreateOptions.dwnEndpoints | string[] | DWN service endpoints for the created DID. Default: ['https://enbox-dwn.fly.dev']. |
connectedDid | string | Use an existing DID instead of creating a new one. |
agent | Web5Agent | Provide a custom agent instance. Defaults to a local Web5UserAgent. |
walletConnectOptions | ConnectOptions | Trigger an external wallet connect flow for delegated identity. |
registration | { onSuccess, onFailure } | Callbacks for DWN endpoint registration status. |
Returns { web5, did, recoveryPhrase?, delegateDid? }.
web5 -- the Web5 instance for all subsequent operations
did -- the connected DID URI (e.g. did:dht:abc...)
recoveryPhrase -- only returned on first initialization
delegateDid -- only present when using wallet connect
defineProtocol(definition, schemaMap?)
Creates a typed protocol definition that enables compile-time path autocompletion and data type checking when used with web5.using().
import type { ProtocolDefinition } from '@enbox/dwn-sdk-js';
const ChatProtocol = defineProtocol({
protocol : 'https://example.com/chat',
published : true,
types: {
thread : { schema: 'https://example.com/schemas/thread', dataFormats: ['application/json'] },
message : { schema: 'https://example.com/schemas/message', dataFormats: ['application/json'] },
},
structure: {
thread: {
message: {},
},
},
} as const satisfies ProtocolDefinition, {} as {
thread : { title: string; description?: string };
message : { text: string };
});
The second argument is a phantom type -- it only exists at compile time. Pass {} as YourSchemaMap to map protocol type names to TypeScript interfaces. The runtime value is ignored.
This gives you:
- Path autocompletion:
'thread', 'thread/message' are inferred from structure
- Typed
data payloads: write('thread', { data: ... }) type-checks against the schema map
- Typed
dataFormat: restricted to the formats declared in the protocol type
web5.using(protocol)
The primary interface for all record operations. Returns a TypedWeb5 instance scoped to the given protocol.
const chat = web5.using(ChatProtocol);
configure()
Installs the protocol on the local DWN. If already installed with an identical definition, this is a no-op. If the definition has changed, it reconfigures with the updated version.
await chat.configure();
records.create(path, request)
Create a new record at a protocol path. The protocol URI, protocolPath, schema, and dataFormat are automatically injected. Returns a TypedRecord<T> where T is inferred from the schema map.
const { record, status } = await chat.records.create('thread', {
data: { title: 'General', description: 'General discussion' },
});
console.log(status.code);
console.log(record.id);
const data = await record.data.json();
To mutate an existing record, use the instance method record.update():
const { record: updated } = await record.update({
data: { title: 'Updated Title', description: 'New description' },
});
records.query(path, request?)
Query records at a protocol path. Returns TypedRecord<T>[] with optional pagination.
const { records, cursor } = await chat.records.query('thread', {
dateSort : 'createdDescending',
pagination : { limit: 20 },
});
for (const thread of records) {
const data = await thread.data.json();
console.log(data.title);
}
if (cursor) {
const { records: nextPage } = await chat.records.query('thread', {
pagination: { limit: 20, cursor },
});
}
records.read(path, request)
Read a single record by filter criteria.
const { record } = await chat.records.read('thread', {
filter: { recordId: 'bafyrei...' },
});
const data = await record.data.json();
records.delete(path, request)
Delete a record by ID.
const { status } = await chat.records.delete('thread', {
recordId: record.id,
});
records.subscribe(path, request?)
Subscribe to real-time changes. Returns a TypedLiveQuery<T> with an initial snapshot of TypedRecord<T>[] plus a stream of typed change events.
const { liveQuery } = await chat.records.subscribe('thread/message');
for (const msg of liveQuery.records) {
const data = await msg.data.json();
console.log(data.text);
}
liveQuery.on('create', (record) => console.log('new:', record.id));
liveQuery.on('update', (record) => console.log('updated:', record.id));
liveQuery.on('delete', (record) => console.log('deleted:', record.id));
All methods also accept a from option to query a remote DWN:
const { records } = await chat.records.query('thread', {
from: 'did:dht:other-user...',
});
Record Instances (TypedRecord<T>)
Methods like create, query, and read return TypedRecord<T> instances -- type-safe wrappers that preserve the data type T inferred from the schema map through the entire lifecycle (create, query, read, update, subscribe).
TypedRecord<T> exposes a typed data.json() that returns Promise<T> instead of Promise<unknown>, eliminating manual type casts. The underlying Record is accessible via record.rawRecord if needed.
Properties:
id | Unique record identifier |
contextId | Context ID (scopes nested records to a parent thread) |
protocol | Protocol URI |
protocolPath | Path within the protocol structure (e.g. 'thread/message') |
schema | Schema URI |
dataFormat | MIME type of the data |
dataCid | Content-addressed hash of the data |
dataSize | Size of the data in bytes |
dateCreated | ISO timestamp of creation |
timestamp | ISO timestamp of most recent write |
datePublished | ISO timestamp of publication (if published) |
published | Whether the record is publicly readable |
author | DID of the record author |
recipient | DID of the intended recipient |
parentId | Record ID of the parent record (for nested structures) |
tags | Key-value metadata tags |
deleted | Whether the record has been deleted |
Data accessors -- read the record payload in different formats:
const obj = await record.data.json();
const text = await record.data.text();
const blob = await record.data.blob();
const bytes = await record.data.bytes();
const stream = await record.data.stream();
Mutators:
const { record: updated } = await record.update({
data: { title: 'Updated Title', body: '...' },
});
const { status } = await record.delete();
Side-effect methods:
await record.send(targetDid);
await record.store();
await record.import();
LiveQuery (Subscriptions) -- TypedLiveQuery<T>
records.subscribe() returns a TypedLiveQuery<T> that provides an initial snapshot of TypedRecord<T>[] plus a real-time stream of deduplicated, typed change events.
const { liveQuery } = await chat.records.subscribe('thread/message');
for (const msg of liveQuery.records) {
const data = await msg.data.json();
renderMessage(data);
}
const offCreate = liveQuery.on('create', (record) => appendMessage(record));
const offUpdate = liveQuery.on('update', (record) => refreshMessage(record));
const offDelete = liveQuery.on('delete', (record) => removeMessage(record));
liveQuery.on('change', ({ type, record }) => {
console.log(`${type}: ${record.id}`);
});
offCreate();
await liveQuery.close();
The underlying LiveQuery is accessible via liveQuery.rawLiveQuery if needed. Events are automatically deduplicated against the initial snapshot -- you won't receive a create event for records already in the records array.
Web5.anonymous(options?)
Creates a lightweight, read-only instance for querying public DWN data. No identity, vault, or signing keys are required.
const { dwn } = Web5.anonymous();
const { records } = await dwn.records.query({
from : 'did:dht:alice...',
filter : { protocol: 'https://example.com/notes', protocolPath: 'note' },
});
for (const record of records) {
console.log(record.id, await record.data.text());
}
const { record } = await dwn.records.read({
from : 'did:dht:alice...',
filter : { recordId: 'bafyrei...' },
});
const { count } = await dwn.records.count({
from : 'did:dht:alice...',
filter : { protocol: 'https://example.com/notes', protocolPath: 'note' },
});
const { protocols } = await dwn.protocols.query({
from: 'did:dht:alice...',
});
Returns ReadOnlyRecord instances (no update, delete, send, or store methods). All calls require a from DID since the reader has no local DWN.
Cookbook
Nested Records
Protocols support hierarchical record structures. Child records reference their parent via parentContextId.
const ChatProtocol = defineProtocol({
protocol : 'https://example.com/chat',
published : true,
types: {
thread : { schema: 'https://example.com/schemas/thread', dataFormats: ['application/json'] },
message : { schema: 'https://example.com/schemas/message', dataFormats: ['application/json'] },
},
structure: {
thread: {
message: {},
},
},
} as const, {} as {
thread : { title: string };
message : { text: string };
});
const chat = web5.using(ChatProtocol);
await chat.configure();
const { record: thread } = await chat.records.create('thread', {
data: { title: 'General' },
});
const { record: msg } = await chat.records.create('thread/message', {
parentContextId : thread.contextId,
data : { text: 'Hello, world!' },
});
const { records: messages } = await chat.records.query('thread/message', {
filter: { parentId: thread.id },
});
Querying with Filters and Pagination
const { records, cursor } = await notes.records.query('note', {
dateSort : 'createdDescending',
pagination : { limit: 10 },
});
if (cursor) {
const { records: page2 } = await notes.records.query('note', {
dateSort : 'createdDescending',
pagination : { limit: 10, cursor },
});
}
const { records: shared } = await notes.records.query('note', {
filter: { recipient: 'did:dht:bob...' },
});
const { records: remote } = await notes.records.query('note', {
from: 'did:dht:alice...',
});
Tags
Tags are key-value metadata attached to records, useful for filtering without parsing record data.
const { record } = await notes.records.create('note', {
data : { title: 'Meeting Notes', body: '...' },
tags : { category: 'work', priority: 'high' },
});
const { records } = await notes.records.query('note', {
filter: { tags: { category: 'work' } },
});
Note: tags must be declared in your protocol's type definition for the DWN engine to index them.
Publishing Records
Published records are publicly readable by anyone, including anonymous readers.
const { record } = await notes.records.create('note', {
data : { title: 'Public Note', body: 'Visible to everyone' },
published : true,
});
Reading Public Data Anonymously
const { dwn } = Web5.anonymous();
const { records } = await dwn.records.query({
from : 'did:dht:alice...',
filter : {
protocol : 'https://example.com/notes',
protocolPath : 'note',
},
});
for (const record of records) {
const note = await record.data.json();
console.log(note.title);
}
Sending Records to Remote DWNs
Records are initially written to the local DWN. Use send() to push them to a remote DWN, or rely on the automatic sync engine.
await record.send(myDid);
await record.send('did:dht:bob...');
The sync engine (enabled by default at 2-minute intervals) automatically synchronizes records between local and remote DWNs. For most use cases, you don't need to call send() manually.
Repository Pattern
The repository() factory provides a higher-level abstraction over TypedWeb5. Instead of passing path strings to every call, you get a structure-aware object with CRUD methods directly on each protocol type -- with automatic singleton detection.
import { defineProtocol, repository, Web5 } from '@enbox/api';
const { web5 } = await Web5.connect({ password: 'secret' });
const TaskProtocol = defineProtocol({
protocol : 'https://example.com/tasks',
published : false,
types: {
project : { schema: 'https://example.com/schemas/project', dataFormats: ['application/json'] },
task : { schema: 'https://example.com/schemas/task', dataFormats: ['application/json'] },
config : { schema: 'https://example.com/schemas/config', dataFormats: ['application/json'] },
},
structure: {
project: {
task: {},
},
config: {
$recordLimit: { max: 1, strategy: 'reject' },
},
},
} as const, {} as {
project : { name: string; color?: string };
task : { title: string; completed: boolean };
config : { defaultView: 'list' | 'board' };
});
const repo = repository(web5.using(TaskProtocol));
await repo.configure();
Collections vs Singletons
The repository automatically detects types with $recordLimit: { max: 1 } and provides different APIs:
Collections (default) -- create, query, get, delete, subscribe:
const { record } = await repo.project.create({
data: { name: 'Website Redesign', color: '#3b82f6' },
});
const { records } = await repo.project.query();
const { records: recent, cursor } = await repo.project.query({
dateSort : 'createdDescending',
pagination : { limit: 10 },
});
const { record: project } = await repo.project.get(recordId);
await repo.project.delete(recordId);
const { liveQuery } = await repo.project.subscribe();
liveQuery.on('create', (record) => console.log('new project:', record.id));
Singletons ($recordLimit: { max: 1 }) -- set, get, delete:
await repo.config.set({
data: { defaultView: 'board' },
});
const { record: config } = await repo.config.get();
const { defaultView } = await config.data.json();
await repo.config.delete(config.id);
Nested Records
Nested types take parentContextId as the first argument:
const { record: task } = await repo.project.task.create(project.contextId, {
data: { title: 'Design mockups', completed: false },
});
const { records: tasks } = await repo.project.task.query(project.contextId);
const { liveQuery } = await repo.project.task.subscribe(project.contextId);
Using Pre-built Protocols
The @enbox/protocols package provides production-ready protocol definitions. Combined with repository(), you get zero-boilerplate typed data access:
import { repository, Web5 } from '@enbox/api';
import {
PreferencesProtocol,
ProfileProtocol,
SocialGraphProtocol,
} from '@enbox/protocols';
const { web5 } = await Web5.connect({ password: 'secret' });
const social = repository(web5.using(SocialGraphProtocol));
await social.configure();
const { record } = await social.friend.create({
data: { did: 'did:dht:alice...', alias: 'Alice' },
});
const profile = repository(web5.using(ProfileProtocol));
await profile.configure();
await profile.profile.set({
data: { displayName: 'Bob', bio: 'Building the decentralized web' },
});
const { record: p } = await profile.profile.get();
await profile.profile.link.create(p.contextId, {
data: { url: 'https://github.com/bob', title: 'GitHub' },
});
const prefs = repository(web5.using(PreferencesProtocol));
await prefs.configure();
await prefs.theme.set({ data: { mode: 'dark', accentColor: '#8b5cf6' } });
await prefs.locale.set({ data: { language: 'en', timezone: 'America/New_York' } });
See @enbox/protocols for the full catalog of 6 protocols and 19 typed data shapes.
Code Generation
For protocols defined externally (e.g. from a spec or shared JSON file), use @enbox/protocol-codegen to generate TypeScript types from a protocol definition and JSON Schemas:
bunx @enbox/protocol-codegen generate \
--definition ./my-protocol.json \
--schemas ./schemas/ \
--name MyProtocol \
--output ./my-protocol.generated.ts
This generates:
- TypeScript interfaces for each type's JSON Schema (via
json-schema-to-typescript)
- A
SchemaMap mapping type names to generated interfaces
- A ready-to-use
defineProtocol() call
See @enbox/protocol-codegen for full documentation.
Advanced Usage
Unscoped DWN Access
For power users who need direct DWN access without protocol scoping (e.g. cross-protocol queries, raw permission management), import from the @enbox/api/advanced sub-path:
import { DwnApi } from '@enbox/api/advanced';
The DwnApi class provides raw records, protocols, and permissions accessors without automatic protocol/path/schema injection. You must provide those fields manually in every call. Most applications should use web5.using() instead.
Permissions
The DWN permission system supports fine-grained access control through permission requests, grants, and revocations.
import { DwnApi } from '@enbox/api/advanced';
const grants = await web5._dwn.permissions.queryGrants();
const request = await web5._dwn.permissions.request({
scope: {
interface : 'Records',
method : 'Write',
protocol : 'https://example.com/notes',
},
});
// Send the request to the target DWN
await request.send('did:dht:alice...');
DID Operations
const { didDocument } = await web5.did.resolve('did:dht:abc...');
Browser Builds — Required Polyfills
The Enbox packages reference several Node.js globals that must be shimmed in browser environments. Most bundlers (Vite, webpack) handle the main bundle automatically, but secondary build targets (e.g., service workers, Web Workers) may need explicit configuration.
Required shims
global | globalThis | @enbox/agent transitive deps |
process.env | {} | @enbox/agent, @enbox/dids |
process.browser | true | @enbox/agent transitive deps |
process.emitWarning | () => {} | @enbox/agent transitive deps |
Vite configuration
For the main app bundle, add to vite.config.ts:
import nodePolyfills from 'vite-plugin-node-stdlib-browser';
export default defineConfig({
define: {
global: 'globalThis',
},
plugins: [nodePolyfills()],
});
Service workers (VitePWA)
Service workers built via VitePWA run in a separate Vite build that does not inherit the main app's polyfill plugins. Two additional steps are needed:
-
Build as IIFE to compile away import.meta.url references:
VitePWA({
strategies: 'injectManifest',
injectManifest: {
rollupFormat: 'iife',
},
})
-
Prepend a process shim via a Rollup plugin:
VitePWA({
injectManifest: {
rollupFormat: 'iife',
buildPlugins: {
rollup: [{
name: 'sw-process-shim',
renderChunk(code) {
const shim = 'if(typeof process==="undefined"){self.process={env:{},browser:true,emitWarning:function(){}};};\n';
return { code: shim + code, map: null };
},
}],
},
},
})
API Reference
Main Exports (@enbox/api)
Web5 | Main entry point -- connect(), anonymous(), using() |
defineProtocol() | Factory for creating typed protocol definitions |
repository() | Factory for creating structure-aware CRUD repositories from TypedWeb5 |
TypedWeb5 | Protocol-scoped API returned by web5.using() -- create, query, read, delete, subscribe |
TypedRecord<T> | Type-safe record wrapper -- data.json() returns Promise<T> |
TypedLiveQuery<T> | Type-safe subscription with TypedRecord<T>[] snapshot and typed change events |
Record | Mutable record instance with data accessors and side-effect methods |
ReadOnlyRecord | Immutable record for anonymous/read-only access |
LiveQuery | Real-time subscription with initial snapshot and change events |
Protocol | Protocol metadata wrapper |
PermissionGrant | Permission grant record |
PermissionRequest | Permission request record |
PermissionGrantRevocation | Permission revocation record |
DidApi | DID resolution |
VcApi | Verifiable Credentials (not yet implemented) |
DwnReaderApi | Read-only DWN API for anonymous access |
Advanced Export (@enbox/api/advanced)
DwnApi | Full unscoped DWN API with records, protocols, permissions |
Key Types
TypedProtocol<D, M> | Typed protocol wrapper with definition and schema map |
ProtocolPaths<D> | Union of valid slash-delimited paths for a protocol definition |
SchemaMap | Maps protocol type names to TypeScript interfaces |
TypedCreateRequest<D, M, Path> | Options for records.create() |
TypedCreateResponse<T> | Response from records.create() -- { status, record: TypedRecord<T> } |
TypedQueryRequest | Options for records.query() |
TypedQueryResponse<T> | Response from records.query() -- { status, records: TypedRecord<T>[], cursor? } |
TypedSubscribeResponse<T> | Response from records.subscribe() -- { status, liveQuery: TypedLiveQuery<T> } |
Repository<D, M> | Repository type -- structure-aware Proxy object with CRUD methods |
DataForPath<D, M, Path> | Resolves TypeScript data type for a protocol path from the schema map |
Web5ConnectOptions | Options for Web5.connect() |
Web5ConnectResult | Return type of Web5.connect() |
RecordModel | Structured data model of a record |
RecordChangeType | 'create' | 'update' | 'delete' |
RecordChange | Change event payload { type, record } |
License
Apache-2.0