Agora

API reference

Every config option, manager method, exported helper, type, route and client hook — from source.

The complete public surface of both packages.

config/collaboration.ts

defineConfig({
  engine?: 'yjs' | 'automerge' | 'partykit' | 'partyserver'
  engineFor?: (docName: string) => CollaborationEngine | Promise<CollaborationEngine>
  documents?: Record<string, CollabDocumentDeclaration>
  authorize?: (ctx: CollabConnectionContext, docName: string) => Promise<CollabPermission>
  storage?: CollaborationStorage
  path?: string
  debounce?: number
  redisUrl?: string
  tokenSecret?: string
  partykit?: PartyKitConfig
  routes?: {
    enabled?: boolean
    prefix?: string
    middleware?: unknown[]
    resolveUser?: ResolveUserFn
  }
})
OptionDefaultNotes
engine'yjs'the fallback engine for anything not routed elsewhere
engineForoverrides per-document engine, for every document
documentspattern → declaration; first match wins
authorizethe global fallback. Absent and unmatched ⇒ deny
storagein-memoryan unconfigured install loses everything on restart
path'/collaboration'the WebSocket upgrade path on the Adonis HTTP server
debounce2000ms of quiet before the state is flushed to storage
redisUrlenables the cross-instance presence roster
tokenSecretderived from appKeysigns the collaboration token; with no key anywhere the library refuses to issue or accept one
partykitrequired when the engine is partykit/partyserver
routes.enabledtruefalse skips auto-registration; call collaborationRoutes yourself
routes.prefix'/collaboration'
routes.middleware[]applied to the whole group
routes.resolveUserctx.auth.check() then ctx.auth.userthrows a 401 when there is no user

PartyKitConfig

{
  roomHost: string          // 'my-app.partykit.dev' or 'localhost:1999'
  party?: string            // default 'main'
  jwtSecret?: string        // HS256 secret shared with the worker
  fetchTimeoutMs?: number   // default 10000
}

documents entries

type CollabDocumentDeclaration<P> = {
  engine?: CollaborationEngine
  authorize?: (
    ctx: CollabConnectionContext,
    info: { docName: string; params: P },
  ) => Promise<CollabPermission>
  load?: (info: { docName: string; params: P }) => Promise<CollabDocumentSeed>
}

type CollabDocumentSeed = Uint8Array | Y.Doc | null | undefined

type DocumentParams<Pattern extends string>
// 'researches/:id/writing'  → { id: string }
// 'a/:x/b/:y'               → { x: string; y: string }
// 'announcements'           → {}
// string                    → Record<string, string>

Inside defineConfig({ documents }), P is DocumentParams<key> — inferred from the pattern the entry sits under, so a plain object literal is all you write:

'researches/:id/writing': {
  async authorize(ctx, { docName, params }) { /* params.id is a string; params.foo is an error */ },
}

defineDocument<P>(declaration) is an identity helper for declarations that live away from their key. Attaching one whose P contradicts the key is a compile error at the config.

load is the document's first content, asked for only when the storage has never stored that name, and persisted before the document is served — once per document, not once per connection. Return the engine's binary state (Y.encodeStateAsUpdate, A.save), a Y.Doc on the Yjs engine, or null for "nothing to seed". A throw is reported and the document opens empty; concurrent connections on a new document seed it once. See Documents.

defineCollection

defineCollection<Prefix extends string>(
  prefix: Prefix,
  declaration?: CollabDocumentDeclaration<{ id: string }>,
): { [K in `${Prefix}/:id`]: CollabDocumentDeclaration<{ id: string }> }

Declares the collection <prefix>/:id — the entry most document types are — and returns it keyed by that pattern, ready to spread into documents. params.id is typed in authorize. The prefix is the literal part of the name ('whiteboards', 'workspaces/main/boards'): a :param, a leading or trailing / throws.

documents: {
  ...defineCollection('whiteboards', { engine: 'automerge', authorize }),
  ...defineCollection('announcements'),           // globals apply
  'researches/:id/writing': { authorize },        // any other shape: write the pattern
}

A declared pattern with no :segment matches exactly one document; the manager warns about each one at boot. documentPatternParams(pattern) lists a pattern's :segments ([] for such a pattern).

matchDocumentPattern(pattern, docName) is exported too — same-segment-count matching, returning the extracted params or null.

CollaborationManager

Resolve it from the container, or reach the singleton through the service:

import collaboration from '@adonis-agora/collaboration/services/main'

const manager = collaboration.current   // ← note `.current`

The service exports an accessor, not the manager

collaboration.current.getDocumentText({ docName }), not collaboration.getDocumentText(...). The indirection is what lets the module be imported before the app has booted; .current throws with a clear message if it is read too early.

MethodReturns
authorize(ctx, docName)Promise<CollabPermission> — the resolved rule, same as the handshake uses
engineFor({ docName })Promise<CollaborationEngine>
getDocumentState({ docName })Promise<Uint8Array> — live document if open, else storage
getDocumentText({ docName })Promise<string> — plain text; '' when it is not a text layout
withLiveDocument({ docName, run })Promise<LiveDocumentResult<T>> — runs run(doc) on the live in-process document; { live: false, reason } when there is none, and then run never runs
hasLiveDocument({ docName })Promise<boolean> — is this process holding the document open right now
getVersionState({ docName, versionId })Promise<Uint8Array> — the binary state of one version; throws on an unknown id
createVersion({ docName, createdBy, label? })Promise<CollabVersion>
listVersions({ docName })Promise<CollabVersion[]>
restoreVersion({ docName, versionId, restoredBy })Promise<void> — applies live; first snapshots the replaced state as a new restored from #<seq> version, attributed to restoredBy
diffVersions({ docName, aId, bId })Promise<{ added: number; removed: number }>
pruneVersions({ keep, docName?, dryRun? })Promise<number> — versions deleted; keep is per document
persistDocument({ docName, state })Promise<void> — throws without configured storage
listPresence({ docName })Promise<PresenceMember[]>[] without Redis
attach(server)Promise<void> — called by the provider
close()Promise<void>
commentsthe CommentService (below)
presencethe PresenceService, when Redis is configured
enginethe configured default engine, as a string

manager.comments

MethodReturns
list(docName, space?)Promise<CollabComment[]> — sorted oldest first
get(docName, commentId)Promise<CollabComment | null> — one comment, through the storage's indexed lookup when it has one (getComment), else a filtered list
create(docName, { space, anchor, body, userId, authorName? })Promise<CollabComment>
resolve(docName, commentId, resolved)Promise<CollabComment | null> — a toggle
remove(docName, commentId)Promise<boolean>

Types

type CollaborationEngine = 'yjs' | 'automerge' | 'partykit' | 'partyserver'

interface CollabConnectionContext {
  userId: string
  user?: { name?: string; avatarUrl?: string | null }
}

interface CollabPermission {
  canRead: boolean      // enforced by the library
  canWrite: boolean     // false ⇒ the connection is read-only; version writes 403
  canComment: boolean   // false ⇒ the comment write endpoints 403
}

type CommentAnchor =
  | { kind: 'text-range'; start: number; end: number; selectedText: string }
  | { kind: 'canvas-object'; shapeId: string; relativeX: number; relativeY: number }
  | { kind: 'timestamp'; at: number; end?: number }

interface CollabComment {
  id: string
  documentName: string
  space: string
  anchor: CommentAnchor
  body: string
  userId: string
  authorName?: string | null
  resolvedAt: string | null
  createdAt: string
  updatedAt: string | null
}

interface CollabVersion {
  id: string            // UUID on Yjs; the document heads on Automerge
  seq: number           // friendly 1, 2, 3…
  label: string | null
  createdBy: string | null
  createdAt: string
}

interface PresenceMember {
  userId: string
  name?: string | null
  avatarUrl?: string | null
}

type LiveDocumentResult<T> =
  | { live: true; value: T }
  | { live: false; reason: LiveDocumentAbsence }

type LiveDocumentAbsence =
  | 'not-loaded'                    // this engine keeps documents here; nobody has this one open
  | 'engine-has-no-live-document'   // the engine syncs elsewhere (partykit), or is not Yjs

A result rather than a T | undefined: "there is no live document" is an ordinary answer, and collapsing it into undefined makes it indistinguishable from a callback that returned undefined itself. The two reasons call for different fallbacks — not-loaded is temporary, engine-has-no-live-document never changes for that document.

CollaborationStorage

interface CollaborationStorage {
  loadDocument(docName: string): Promise<{ state: Uint8Array } | null>
  saveDocument(docName: string, state: Uint8Array, meta?: Record<string, unknown>): Promise<void>

  listVersions(docName: string, page?: ListPageOptions): Promise<CollabVersion[]>
  saveVersion(docName: string, version: CollabVersion, snapshot: Uint8Array): Promise<void>
  loadVersionSnapshot(docName: string, versionId: string): Promise<Uint8Array | null>
  pruneVersions(options: { keep: number; docName?: string; dryRun?: boolean }): Promise<number>

  listComments(docName: string, space?: string, page?: ListPageOptions): Promise<CollabComment[]>
  getComment?(docName: string, commentId: string): Promise<CollabComment | null>  // optional
  saveComment(docName: string, comment: CollabComment): Promise<void>
  deleteComment(docName: string, commentId: string): Promise<void>
}
interface ListPageOptions {
  page?: number  // 1-based, default 1
  size?: number  // default 50, capped at 200
}

ListPageOptions mirrors @adonis-agora/filter's pagination shape (page/size) so every @adonis-agora/* package pages identically. Omit it entirely to get every row — that is what the internal seq and restore lookups rely on; the HTTP routes always supply one.

Implementations: lucidStorage({ connection }) / LucidStorage, FileSystemStorage(baseDir?), InMemoryCollaborationStorage.

Token helpers

issueToken(config, user, docName, authorize?): Promise<{ token; wsUrl; engine; expiresAt }>
buildWsUrl(config, docName): string

// One signed format for every engine; the key is what differs.
signCollabToken(payload, secret, expiresInSeconds?): string
verifyCollabToken(token, secret, expectedDocName): { userId; docName; user?; exp } | null
verifySelfHostedToken(token, docName, secret?): Promise<CollabConnectionContext | null>
signPartyKitToken   // = signCollabToken, signed with partykit.jwtSecret
verifyPartyKitToken // = verifyCollabToken

// The signing key: explicit → config/collaboration.ts → appKey → throw.
resolveTokenSecret(secret?): Promise<string>
COLLAB_TOKEN_TTL_SECONDS // 300

class CollabForbiddenError extends Error         // authorize denied canRead
class CollabAuthorizationError extends Error     // authorize itself threw — retryable, not a denial
class CollabUnauthorizedError extends Error      // no user resolved
class CollabTokenSecretMissingError extends Error // no signing key anywhere — issue and verify both refuse

expectedDocName is not optional on the verifiers: the client picks the document it connects to, so a token that is not checked against it opens every document its holder can name.

issueToken requires an authorize and throws CollabForbiddenError without one: an absent rule is a misconfiguration, not a grant. Pass the manager's, not your config's, so per-document rules apply.

issueCollaborationToken(
  { docName: string; user?: { id: string; name?: string | null }; ctx?: HttpContext },
  options?: { authorize?; manager?; engine?; path?; partykit?; resolveUser? },
): Promise<{ token: string; wsUrl: string; engine: string; expiresAt?: number }>

Issues a credential outside the route — for a server-rendered page that hands it to the client, so the browser opens the socket without GET /collaboration/token in front of it. Same authorize, same per-document engine, same wsUrl; every option defaults exactly as the routes' do. Throws CollabUnauthorizedError (no caller) or CollabForbiddenError (denied) instead of returning a response. See Token flow.

Observability

onCollaborationError(listener): () => void        // subscribe; returns unsubscribe
setCollaborationLogger(logger | undefined): void  // the provider installs the Adonis logger
reportCollaborationError(event): void             // report your own; never throws
collaborationEvents                               // the EventEmitter
COLLAB_ERROR_EVENT                                // its event name — deliberately not 'error'

interface CollabErrorEvent {
  scope: 'storage' | 'authorize'
  operation: string      // e.g. 'saveDocument', 'onAuthenticate'
  docName?: string
  error: unknown
}

Storage failures inside the Hocuspocus hooks, and a throw from authorize at the handshake, are reported here instead of vanishing. With neither a logger nor a listener they reach console.error.

COLLAB_ERROR_EVENT is not 'error' on purpose: an EventEmitter with no 'error' listener throws, which would turn the observability hook into a second outage.

A denial and an evaluation failure stay distinct all the way to the client. On the Yjs handshake the former is a CollabForbiddenError and the latter a CollabAuthorizationError; on the Automerge socket they are close codes 4003 (final) and 4503 (retryable).

Routes

collaborationRoutes(router, {
  prefix?: string
  middleware?: unknown[]
  resolveUser?: ResolveUserFn
  authorize?: AuthorizeFn
  manager?: CollabManagerLike | (() => Promise<CollabManagerLike>)
  engine?: string
  path?: string
  partykit?: { roomHost?; jwtSecret?; party? }
}): void

Synchronous, so it composes inside router.group(). Omit authorize and the routes resolve the rule through the manager — the same one the handshake uses — and deny when nothing declares one.

MethodPathBody / query
GET/token?doc=
GET/comments?doc=&space=&page=&size=
POST/comments{ docName, space, anchor, body, authorName? } — author is the caller
PATCH/comments/:id?doc= · { resolved? } (default true) — author or canWrite
DELETE/comments/:id?doc= — author or canWrite
GET/versions?doc=&page=&size=
POST/versions{ docName, label? }createdBy is the caller
POST/versions/restore{ docName, versionId }204
GET/state?doc=application/octet-stream
POST/state?doc= · raw body · x-collab-worker-secret

GET /comments and GET /versions page with page/size — offset-based, page 1-based (default 1) and size clamped server-side to 1..200 (default 50). Same shape as @adonis-agora/filter, so every Agora list endpoint pages identically.

Also exported: defaultResolveUser (authenticates via ctx.auth.check() before reading the user), canMutateComment({ comment, userId, permission }) and secretsMatch(received, expected).

Document-name types

InferCollabDocumentNames<typeof collaborationConfig>  // union of declared patterns
CollabDocumentNameFor<'researches/:id/writing'>       // `researches/${string}/writing`

defineConfig is generic, so the literal keys of documents survive into the config's type. See Typed registry.

Ace commands

CommandFlags
collaboration:init--engine=yjs|automerge|partykit
make:collab-document <name>--spaces=a,b · --anchors=text-range,canvas-object,timestamp

Client — @adonis-agora/collaboration-client

CollaborationProvider

<CollaborationProvider
  baseUrl?={string}          // optional — falls back to location.origin
  getHeaders?={() => Record<string, string> | Promise<Record<string, string>>}
  createTransport?={(info, doc, docName) => CollabTransport}
  fetchImpl?={typeof fetch}
  initialTokens?={Record<string, CollabTokenInfo>}  // pre-issued, by docName
/>

initialTokens carries tokens the server issued while rendering the page (issueCollaborationToken), keyed by document name. Each is used for that document's first connection only; reconnects fetch a fresh one, and an expired or malformed entry falls back to the HTTP endpoint.

Hooks

useCollabDoc({ docName, token? }): {   // token: pre-issued, first connection only
  doc: Y.Doc                 // stable for the provider's lifetime
  collabDoc: CollabDoc       // the same document behind read/write/onUpdate
  status: CollabStatus
  synced: boolean            // the server's initial state has been applied
  error: Error | null
}

useAutomergeDoc<T>({ docName, WebSocketImpl? }): {
  doc: A.Doc<T> | null
  status: CollabStatus
  synced: boolean            // the server's state has been merged on THIS connection
  pendingChanges: number     // local changes not yet written to the socket
  error: Error | null
  change(fn: A.ChangeFn<T>): void
}

useAwareness({ docName, user? }): {
  peers: CollabPeer[]                              // everyone except you
  status: CollabStatus
  awareness: Awareness | null                      // the channel; null until connected
  setLocalState(state: Record<string, unknown> | null): void
  setLocalStateField(field: string, value: unknown): void
}

// `user` is your identity, published into the awareness `user` field — the
// field every other client's `peers` reads. Without it the room is anonymous.
interface CollabLocalUser {
  userId?: string
  name?: string
  avatarUrl?: string | null
  [key: string]: unknown
}

useCollabEditor<S>({ docName, editorId, adapter, doc? }): {
  state: S
  update(next: S): void
  status: CollabStatus
  synced: boolean
  peers: CollabPeer[]
}

useExcalidrawSync({ doc, excalidrawAPI, editorId? }): void
// doc: CollabDoc | Y.Doc | null — either of the two useCollabDoc returns
// editorId defaults to 'excalidraw'; the scene is last-write-wins on one key

useComments({ docName, space? }): {
  comments: CollabComment[]
  loading: boolean
  error: Error | null
  refresh(): Promise<void>
  create(input): Promise<CollabComment>
  resolve(commentId, resolved?): Promise<void>
  remove(commentId): Promise<void>
}

useVersions({ docName }): {
  versions: CollabVersion[]
  loading: boolean
  error: Error | null
  refresh(): Promise<void>
  create(label?): Promise<CollabVersion>
  restore(versionId): Promise<void>
}

type CollabStatus = 'connecting' | 'connected' | 'disconnected' | 'error'

Editor adapters

createExcalidrawAdapter<S>(): CollabEditorAdapter<{ elements: S[]; appState?: Record<string, unknown> }>
createTldrawAdapter<S>(): CollabEditorAdapter<{ snapshot: S }>
createTextAdapter(): CollabEditorAdapter<string>

interface CollabEditorAdapter<S> {
  empty(): S
  create(doc: CollabDoc, editorId: string): { state: S; update(next: S): void }
}

interface CollabDoc {
  read<T>(key: string): T | undefined
  write(key: string, value: unknown): void
  onUpdate(callback: () => void): () => void
}

Session and REST client

class DocSession {
  constructor(
    docName: string,
    config: CollaborationClientConfig,
    options?: { registry?: DocSessionRegistry },   // the provider passes its map
  )
  readonly doc: Y.Doc
  readonly docName: string
  get transport(): CollabTransport | undefined
  get engine(): string | undefined
  get error(): Error | null
  get synced(): boolean          // initial state applied, not just "socket open"
  get refCount(): number         // mounted consumers holding it open
  get isDestroyed(): boolean
  getStatus(): CollabStatus
  getSnapshot(): DocSessionSnapshot             // { status, error, synced }
  subscribe(listener: () => void): () => void   // also starts the session
  start(): Promise<void>                        // idempotent
  retain(): () => void   // hold it open; returns its own release
  release(): void        // the last one calls stop()
  stop(): void           // tears down the transport, KEEPS the Y.Doc
  destroy(): void        // terminal: destroys the Y.Doc, evicts from the map
}

fetchToken(config, docName)
listComments(config, docName, space?)
createComment(config, input)
resolveComment(config, docName, commentId, resolved)
deleteComment(config, docName, commentId)
listVersions(config, docName)
createVersion(config, docName, label)
restoreVersion(config, docName, versionId)
collabFetch<T>(config, path, init?)

class CollabRestError extends Error { status: number; body: string }

Transports: HocuspocusCollabTransport, PartyKitCollabTransport, PartyServerCollabTransport, and defaultTransportFactory.

interface CollabTransport {
  readonly doc: Y.Doc
  readonly awareness?: Awareness | null
  getStatus(): CollabStatus
  isSynced?(): boolean          // optional; the session falls back to the status
  subscribe(listener: () => void): () => void
  destroy(): void
}

Only Hocuspocus can separate "socket open" from "initial state applied", so only it implements isSynced. For a transport that does not, session.synced reports getStatus() === 'connected' — never less informative than the status, and never more.

On this page