LegendKeeper SDK guide
This guide covers every capability of @legendkeeper/sdk, in the order you need them. The quickstart installs the client and writes a first page. The style guide explains which fields and values to choose so that your content looks like content made in the app.
Every TypeScript example in this guide is a file in the package's examples/ directory. The API test suite runs each one against a live server, so a broken example fails the build. Each example creates what it needs and deletes it before it exits, so you can run it as-is against your own project.
Getting started
Install
npm install @legendkeeper/sdk
The package has no runtime dependencies. It needs a global fetch, which Node 20 and later and edge runtimes provide. It ships ES module and CommonJS builds with type declarations.
Get an API key
To create a key, open the project in LegendKeeper and go to Project Settings → Developers → API Keys. Enter a name, choose a role, and create the key. You need the ADMIN role to create a key.
The secret is shown once, when you create the key. Copy it then. The key list identifies each key by its name and keyPrefix.
API keys require the project owner to be on a paid plan. A trial does not count.
A key belongs to one project and has a role, MEMBER or ADMIN, chosen when you create it. Member is the default. A key acts as the user who created it, with the lower of the key's role and that user's current role in the project. If the creator is demoted from admin to member, the key acts as a member from then on.
A key that acts as a member follows the same rules as a member in the app. An integration that builds top-level content needs an ADMIN key.
A key acts only in its own project. A key in browser code is readable by anyone who loads the page, so call the API from a server you control.
Details:
- The operations marked "ADMIN role" in this guide return 403
FORBIDDENto a key that acts as a member. - A key created in Project Settings has at most the ADMIN role, so operations that only the project owner may call return 403
FORBIDDENto it.
Run the examples
Every example reads the key from LEGENDKEEPER_API_KEY. Most examples create resources at the root of the tree, so they expect an ADMIN key. To point the client at another server, such as a proxy or a mock server, set LEGENDKEEPER_BASE_URL. If it is unset, the client uses https://www.legendkeeper.com.
export LEGENDKEEPER_API_KEY=lk_...
npx tsx first-call.ts
The examples use top-level await, so they run as ES modules. Set "type": "module" in your package.json, or give the file an .mts extension. tsx runs TypeScript directly. You can also compile with tsc and run the output with node.
Make a first call
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const project = await lk.project()
console.log(`Connected to ${project.name} (${project.id})`)
const resource = await lk.resources.create({ name: 'The Tree Fort' })
console.log(`Created ${resource.name} (${resource.id})`)
await lk.resources.delete({ resourceId: resource.id })
new LegendKeeper({ apiKey }) makes no request. The first call does.
lk.project() returns the project the key belongs to. Every operation whose path starts with a project takes an optional projectId. If you omit it, the client fills it in from the key. The client looks up the project once, on the first call that needs it, and reuses it for the life of the client. Pass projectId yourself to skip that one request.
Without documents, a new resource starts with one document of type blank named Main. The first page, board, map or timeline write converts it to that type. See documents.
Operations take up to two arguments:
- The first holds the path parameters, such as
resourceIdanddocumentId. - The second holds everything else: query parameters, the request body,
ifMatch,idempotencyKeyandsignal.
An operation whose only path parameter is projectId takes the options as its only argument, as lk.resources.create({ name }) does above.
Details:
- The client resolves the project with one
projects.listcall the first time it needs it, and reuses the result for the life of the client. - If the resolution fails, the client tries again on the next call.
- With a credential that reaches more than one project, resolution throws a
TypeErrorthat asks for an explicitprojectId.
Client options
import { ConnectionError, LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({
apiKey: process.env.LEGENDKEEPER_API_KEY!,
timeoutMs: 10_000,
maxRetries: 3,
fetch: async (url, init) => {
const response = await fetch(url, init)
console.log(init.method, url, response.status, response.headers.get('x-request-id'))
return response
},
})
const first = await lk.resources.list({ limit: 10, signal: AbortSignal.timeout(5_000) })
console.log(`${first.data.length} resources on the first page`)
const controller = new AbortController()
controller.abort()
try {
await lk.resources.list({ signal: controller.signal })
} catch (error) {
if (!(error instanceof ConnectionError)) throw error
console.log(`aborted before a response arrived: ${error.message}`)
}
| Option | Default | Effect |
|---|---|---|
apiKey |
required | Sent as Authorization: Bearer <key>. The constructor throws a TypeError without it. |
baseUrl |
https://www.legendkeeper.com |
The host to send requests to, such as a proxy or a mock server in tests. |
timeoutMs |
30000 |
The time limit for each attempt, in milliseconds. Each retry starts a fresh limit. |
maxRetries |
2 |
How many times the client retries a failed request. 0 turns retries off. See Rate limits and retries. |
fetch |
globalThis.fetch |
The function that sends each request, with the signature (url: string, init: RequestInit) => Promise<Response>. The constructor throws a TypeError when neither exists. |
idempotencyKeys |
true |
Whether every POST sends a generated Idempotency-Key. With false, a key you pass as idempotencyKey on a call is still sent. See Idempotent writes. |
A custom fetch sees every attempt, including retries. Use it to log, trace, add headers, or route requests through a proxy. It must return a Response.
Every call accepts an AbortSignal as signal in its options. When the signal aborts, for any reason, the call rejects with a ConnectionError and is not retried. If you set both timeoutMs and signal, the attempt ends at whichever comes first.
Every error the API returns has a requestId, the value of the response's x-request-id header. A ConnectionError has none. Include it when you report a problem. It identifies the server log line for the request.
Details:
- When the
baseUrloption is absent, the client readsLEGENDKEEPER_BASE_URL, but only whereprocess.envexists. Elsewhere, it uses production. - An empty
LEGENDKEEPER_BASE_URLcounts as unset. - An error thrown by a custom
fetchbecomes aConnectionError.
Errors
Every failed call throws. If the server returns an error status, the client throws a subclass of LegendKeeperError. If no response arrives, the client throws ConnectionError.
import { ConnectionError, LegendKeeper, LegendKeeperError, NotFoundError, ValidationError } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
async function report(attempt: () => Promise<unknown>): Promise<void> {
try {
await attempt()
console.log('succeeded')
} catch (error) {
if (error instanceof ValidationError) {
for (const issue of error.errors ?? []) console.log(`${error.code} at ${issue.path}: ${issue.message}`)
} else if (error instanceof NotFoundError) {
console.log(`${error.code}: ${error.message}`)
} else if (error instanceof LegendKeeperError) {
console.log(`${error.status} ${error.code ?? 'an unknown code'}; quote request ${error.requestId}`)
} else if (error instanceof ConnectionError) {
console.log(`no response: ${error.message}`)
} else {
throw error
}
}
}
await report(() => lk.resources.create({ name: '' }))
await report(() => lk.resources.list({ limit: 500 }))
await report(() => lk.resources.get({ resourceId: 'no-such-resource' }))
LegendKeeperError has these fields:
| Field | Value |
|---|---|
status |
The HTTP status. |
code |
One of the catalogue codes below, typed as ErrorCode. A code that this version of the SDK does not know reads as undefined. |
message |
A human-readable message. Its meaning is stable but its wording can change, so branch on code, not on message. |
errors |
On 400 VALIDATION_ERROR, INVALID_CONTENT and UNSUPPORTED_KIND, and on 409 DUPLICATE_ID from a block write, one { path, message } per failing field or block. Otherwise undefined. |
requestId |
The x-request-id header, or the body's requestId when the header is absent. |
response |
The raw Response. |
path names the field or block in dotted form, such as name, limit or block.children[0].level. walk reports paths in the same form.
Details:
- A request body with an unknown field is rejected with
VALIDATION_ERRORat that field's path, so a misspelled option is not ignored.
Error classes
Each class matches a status, so an instanceof check and a code check agree.
| Status | Class | What to do |
|---|---|---|
| 400 | ValidationError |
Fix the request. errors identifies each failing field or block. Resending the same request fails the same way. |
| 401 | AuthenticationError |
The key is missing, unknown, revoked or expired. Create a new key. |
| 403 | PermissionError |
The key's role or the resource's permissions do not allow the operation, the operation does not accept API keys, or the path names another project. |
| 404 | NotFoundError |
The entity does not exist, is deleted, or is hidden from the key's role. |
| 409 | ConflictError |
The request conflicts with the entity's state. Read the entity again and decide. |
| 412 | VersionMismatchError |
The document changed since you read it. Read it again and reapply your change. See Versioned writes. |
| 429 | RateLimitError |
A rate limit is exhausted. The client has already retried any wait of 8 seconds or less. Wait retryAfterMs, when present, before you try again. |
| 5xx | ServerError |
The server failed. Report the requestId, which identifies the server log line. |
| other | LegendKeeperError |
Any other status, including 402, 405, 413 and 422. Branch on code. |
VersionMismatchError adds currentVersion, the version parsed from the message, which can be undefined. RateLimitError adds retryAfterMs, read from Retry-After.
ConnectionError means no response arrived: a DNS, TCP or TLS failure, a timeout, or an abort. It is not a LegendKeeperError. Its cause is the underlying error, with the name TimeoutError for the client's timeout. For an abort through signal, it is the signal's reason.
Error codes
Every error response from the API has the body { message, code, requestId, errors? }. Each code has exactly one status.
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR |
The body or query fails the schema. errors[].path identifies each field. |
| 400 | INVALID_CONTENT |
The block tree is schema-valid but the editor cannot hold it. |
| 400 | UNSUPPORTED_KIND |
The request creates an unsupported block or property, or echoes an unsupported id the document does not have. |
| 400 | KIND_MISMATCH |
The target document, block or calendar is not a kind the route accepts. |
| 401 | UNAUTHENTICATED |
No credential, or an unknown, revoked or expired key. |
| 402 | SUBSCRIPTION_LAPSED |
The project owner's plan does not allow the write. |
| 402 | PAID_PLAN_REQUIRED |
The project owner is not on a paid plan. |
| 403 | FORBIDDEN |
The caller is not a member with the required role. |
| 403 | WRONG_PROJECT |
The key's project is not the path's project. |
| 403 | INSUFFICIENT_SCOPE |
The key lacks a scope the operation requires. |
| 403 | FEATURE_UNAVAILABLE |
The request uses a field of a feature that is not available yet. |
| 404 | NOT_FOUND |
A missing entity, including a placement anchor. |
| 405 | METHOD_NOT_ALLOWED |
The path exists, but the method does not. |
| 409 | DUPLICATE_ID |
A supplied id already exists. On a block write, errors[].path identifies the field. |
| 409 | AMBIGUOUS |
A block id occurs more than once in a legacy document. |
| 409 | ALREADY_DELETED |
The entity is already deleted. |
| 409 | HAS_CHILDREN |
The resource has child resources that are not deleted. Pass cascade=true to delete them too. |
| 409 | NOT_DELETED |
The entity to restore is not deleted. |
| 409 | PARENT_DELETED |
The resource's parent is deleted. Restore the parent first. |
| 409 | IN_PROGRESS |
A prior operation on the entity has not finished. |
| 409 | IDEMPOTENCY_KEY_REUSED |
The idempotency key was used for a different request. |
| 409 | TOO_MANY_PENDING_INVITES |
The project has the maximum number of pending invites. |
| 412 | VERSION_MISMATCH |
If-Match does not equal the current version. |
| 422 | DOCUMENT_TOO_LARGE |
The write would grow the document past its size limit. Delete content before you add more. |
| 413 | PAYLOAD_TOO_LARGE |
Reserved. A request body over 1 MB is refused by the host before the API runs. That response is plain text, so the error has status 413 and no code or requestId. |
| 413 | STORAGE_QUOTA_EXCEEDED |
The project owner's storage quota is reached. |
| 429 | RATE_LIMITED |
A key or project tier is exhausted. See Retry-After. |
| 429 | UPLOAD_RATE_EXCEEDED |
The upload tier is exhausted. |
| 500 | INTERNAL |
An unexpected failure. |
ERROR_CODES exports the codes as an array, and ErrorCode is their union type. TypeScript checks a switch (error.code) for exhaustiveness.
Details:
- A
blankdocument does not fail withKIND_MISMATCH. The first page, board, map or timeline write converts it to that type, as documents describes.
Rate limits and retries
import { LegendKeeper, RateLimitError } from '@legendkeeper/sdk'
const maxRetries = 4
const lk = new LegendKeeper({
apiKey: process.env.LEGENDKEEPER_API_KEY!,
maxRetries,
fetch: async (url, init) => {
const response = await fetch(url, init)
const remaining = response.headers.get('x-ratelimit-remaining')
const reset = response.headers.get('x-ratelimit-reset')
if (remaining !== null && reset !== null) {
console.log(`${remaining} left in the window that resets at ${new Date(Number(reset) * 1000).toISOString()}`)
}
return response
},
})
try {
const page = await lk.resources.list({ limit: 5 })
console.log(`read ${page.data.length} resources`)
} catch (error) {
if (!(error instanceof RateLimitError)) throw error
console.log(`still limited after ${maxRetries} retries; the server asks for ${error.retryAfterMs} ms`)
}
Rate limits
API-key requests are counted in six tiers. A request that exhausts any tier returns 429 with code: "RATE_LIMITED". The client retries it and throws RateLimitError when its retries run out.
| Scope | Window | Limit |
|---|---|---|
| Writes (POST, PUT, PATCH, DELETE), per key | 1 minute | 120 |
| Writes (POST, PUT, PATCH, DELETE), per key | 1 hour | 3,000 |
| All requests, per key | 1 minute | 300 |
| All requests, per key | 1 hour | 6,000 |
| All requests, per key | 1 day | 60,000 |
| All requests, per project across keys | 1 hour | 12,000 |
Every key of a project shares the per-project tier, so one busy key slows the others.
Responses include X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset for the tier with the fewest requests remaining. The client does not expose response headers on a successful call. Read them through a custom fetch, as the example does.
A bulk import reaches the write limit of 120 a minute first. Space writes at least half a second apart, or let the client wait and retry. An import of more than 3,000 writes also reaches the limit of 3,000 writes an hour; space those writes 1.2 seconds apart.
To create many resources, use resources.createMany. One call creates up to 100 resources and counts as one write. Its items count against a separate budget of 600 items a minute and 6,000 an hour.
Retries
The client retries a failed attempt up to maxRetries times:
GET,PUT,PATCHandDELETEare retried on 429, 502, 503 and 504, on a timeout, and on a connection failure.POSTis retried in the same cases, because everyPOSTsends anIdempotency-Keyand every attempt of one call sends the same key. It is also retried on 409IN_PROGRESS, which the server returns while an earlier attempt with that key is still running. See Idempotent writes.- A request aborted through
signal, a 500, and any 4xx other than 429 are never retried.
Before a retry, the client waits for the Retry-After value when the response has one. A 429 or 503 whose Retry-After is longer than 8 seconds is not retried: the call throws at once, and the error's retryAfterMs says how long to wait. Without Retry-After, the client uses an exponential backoff with jitter: the first wait is 250–500 ms, and each later wait doubles, up to 8 seconds.
Details:
- A
POSTwithout a key is retried only on 429 and on a connection failure. APOSThas no key only withidempotencyKeys: falseand noidempotencyKeyon the call. - A
POSTwithout a key that timed out or returned 502, 503 or 504 is not retried, because the server may have applied it. - 409
IN_PROGRESSis retried only on a request that has a key. - When
Retry-Afterexceeds 8 seconds, the client waits 8 seconds. A request that meets a spent per-minute tier can then exhaust its retries and throwRateLimitError. ItsretryAfterMssays how long to wait.
Idempotent writes
import { ConflictError, LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({
apiKey: process.env.LEGENDKEEPER_API_KEY!,
fetch: async (url, init) => {
const response = await fetch(url, init)
const headers = new Headers(init.headers)
const key = headers.get('idempotency-key')
if (key !== null) console.log(`${init.method} with key ${key}, replayed: ${response.headers.get('idempotent-replay') ?? 'false'}`)
return response
},
})
const key = `import-gunter-${Date.now()}`
const gunter = await lk.resources.create({ name: 'Gunter', tags: ['Penguin'], idempotencyKey: key })
try {
const again = await lk.resources.create({ name: 'Gunter', tags: ['Penguin'], idempotencyKey: key })
console.log(`one resource: ${again.id === gunter.id}`)
try {
await lk.resources.create({ name: 'Gunther, Destroyer of Worlds', idempotencyKey: key })
} catch (error) {
if (!(error instanceof ConflictError)) throw error
console.log(`${error.status} ${error.code}`)
}
const orgalorg = await lk.resources.create({ name: 'Orgalorg', parentId: gunter.id })
await lk.resources.delete({ resourceId: orgalorg.id })
} finally {
await lk.resources.delete({ resourceId: gunter.id })
}
An Idempotency-Key header makes a POST safe to send twice. The server stores the response to the first request with a key. A later request with the same key and body gets that stored response, and the write does not run again. If a create times out or its connection drops, send it again and it still creates one entity.
The client sends a key on every POST operation. It generates one UUID per call and sends that key on every retry of the call, so the retries described in Retries never write twice. PUT, PATCH and DELETE send no key, because sending one of them twice leaves the same stored result. The second response can still differ: a retried DELETE whose first attempt was applied returns 404 or 409 ALREADY_DELETED, and a retried ifMatch write returns 412. Read the entity before you treat either as a failure.
To send your own key, pass idempotencyKey in a POST call's options. A key derived from your own data, such as import-row-42, makes a whole script safe to rerun. A second run of an import replays the first run's creates instead of making a second copy of each row.
To stop the generated key, set idempotencyKeys: false in the client options. A key passed as idempotencyKey is still sent. Only operations that send POST accept idempotencyKey.
| Situation | Answer |
|---|---|
| First request with a key | The write runs. A 2xx response, or a 4xx response other than the refusals listed below, is stored under the key. |
| Same key, same method, path and body | The stored status and body, with Idempotent-Replay: true and Idempotent-Original-Request-Id set to the first request's x-request-id. Nothing is written. |
| Same key while the first request is still running | 409 IN_PROGRESS. The client waits and retries up to maxRetries times, and receives the stored response once the first request finishes. If the first request is still running after those retries, the call throws ConflictError with IN_PROGRESS; retry later with the same key. |
| Same key, different method, path or body | 409 IDEMPOTENCY_KEY_REUSED, thrown as ConflictError. Nothing is written. Use a new key for a different request. |
The first request was refused before any write: 400 VALIDATION_ERROR, 401, 403 or 404 |
Nothing is stored, and the key is released. You can send the corrected request with the same key. |
| The first request answered 5xx or 429 | Nothing is stored, and the key is released. A retry with the key runs the write again. |
Details:
- The server keeps the response for each key for 24 hours.
- The server compares bodies after sorting their keys, so the same fields in a different order count as the same body.
- A replay returns the response exactly as it was, so a create replayed after the entity was deleted still returns the created entity.
- A replayed 4xx is thrown again.
- The stored 4xx responses include 409
DUPLICATE_ID, 400KIND_MISMATCH, 412VERSION_MISMATCHand 422DOCUMENT_TOO_LARGE. The comparison does not includeIf-Match, so after a 412, re-read the version and send the write with a new key. - A key is 1–255 characters from
A-Z,a-z,0-9,.,_and-. Any other key returns 400VALIDATION_ERROR. - Keys are scoped to the API key that sent them.
- A response body larger than 64 KiB is not stored. A retry with the same key runs the write again.
lk.requestsends no generated key. To send one, putIdempotency-Keyin itsheaders.- A
POSTthroughlk.requestwith that header is retried like any otherPOSTwith a key, and one without it follows the rule for aPOSTwithout a key.
Pagination
import { LegendKeeper, type Page, type components } from '@legendkeeper/sdk'
type ResourceListItem = components['schemas']['ResourceListItem']
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
const kingdoms: string[] = []
try {
for (const name of ['Candy Kingdom', 'Ice Kingdom', 'Fire Kingdom', 'Lumpy Space', 'Wildberry Kingdom']) {
kingdoms.push((await lk.resources.create({ name, parentId: ooo.id })).id)
}
for await (const kingdom of await lk.resources.list({ parentId: ooo.id, limit: 2 })) {
console.log(kingdom.name)
}
for await (const member of await lk.members.list()) {
console.log(member.id, member.role)
}
let page: Page<ResourceListItem> | null = await lk.resources.list({ parentId: ooo.id, limit: 2 })
while (page !== null) {
console.log(page.data.map((kingdom) => kingdom.name).join(', '), page.hasMore ? 'then more' : 'and no more')
page = await page.next()
}
const documents = await lk.documents.list({ resourceId: ooo.id })
console.log(`${documents.data.length} documents, hasMore ${documents.hasMore}, nextCursor ${documents.nextCursor}`)
} finally {
for (const id of kingdoms) await lk.resources.delete({ resourceId: id })
await lk.resources.delete({ resourceId: ooo.id })
}
Every list operation returns a Page, and so does search.search. A Page has:
data: the items on this page.hasMore: whether another page follows.nextCursor: the cursor for the next page, ornull.next(): fetches the next page, or resolvesnullafter the last one.[Symbol.asyncIterator]:for awaitover a page yields every item from this page to the end of the collection, fetching as it goes.
Use for await to read a whole collection. Use next() to handle one page at a time. Each call to next() sends a request, so keep the page it returns instead of calling it twice.
Server-paged lists: assets.list, projects.list, resources.list, templates.list, timelines.listEvents.
These lists take limit, the page size, and cursor. A cursor is opaque: send back the nextCursor you received, never one you built or parsed.
Every other list returns the whole collection in one page with hasMore false and nextCursor null, so the same for await loop works on every list. The example reads members.list, which is not paged, with that loop.
Details:
- A cursor the endpoint did not issue returns 400
VALIDATION_ERRORwith the field inerrors[].path. search.searchreturns one page.- Items added while you page may or may not appear, depending on where they sort.
Versioned writes
import { LegendKeeper, VersionMismatchError } from '@legendkeeper/sdk'
import { p } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const lsp = await lk.resources.create({ name: 'Lumpy Space Princess' })
try {
const page = lsp.documents[0]!
const doc = { resourceId: lsp.id, documentId: page.id }
const read = await lk.documents.getBlocks(doc)
const written = await lk.documents.replaceBlocks(doc, { blocks: [p('Lives in Lumpy Space. Oh my glob.')], ifMatch: read.version })
console.log(`read at ${read.version}, written at ${written.version}`)
try {
await lk.documents.replaceBlocks(doc, { blocks: [p('Lives in the woods now. Whatever.')], ifMatch: read.version })
} catch (error) {
if (!(error instanceof VersionMismatchError)) throw error
console.log(`rejected; the document is at ${error.currentVersion}`)
const fresh = await lk.documents.getBlocks(doc)
const merged = await lk.documents.replaceBlocks(doc, {
blocks: [...fresh.blocks, p('Moved to the woods after a fight with her parents.')],
ifMatch: fresh.version,
})
console.log(`${merged.blocks.length} blocks at ${merged.version}`)
}
} finally {
await lk.resources.delete({ resourceId: lsp.id })
}
Page content is versioned. documents.getBlocks, blocks.get and every write that takes ifMatch return the document's version. A write returns the version the document has after it. The version is an opaque string, so compare it only for equality.
Pass a version back as ifMatch on a write. The server applies the write only if the document is still at that version. Otherwise it returns 412, and the client throws VersionMismatchError. Without ifMatch, the write overwrites any edit made since your read, in the app or by another client.
These operations take ifMatch:
documents.replaceBlocks- Every
blockswrite - The row writes on sidebar properties:
properties.createRow,properties.updateRowandproperties.deleteRow - Every
boardswrite
On a mismatch, read the document again, reapply your change to what you read, and write with the new version. VersionMismatchError.currentVersion has the version the server reported, when it can be parsed from the message. Reading again is the reliable path, since you need the new content anyway.
Details:
- A board has its own
version, whichboards.getContent,boards.listShapesand every board write return. - Calendars have a
versionas well. See Calendars. - The client copies the
ETagheader intoversionwhen the body lacks it. - A write that returns no body, such as
blocks.delete, returns no version. Read the document to get the next one.
Writing content
The block model
For a page's plain text, read text from documents.getBlocks.
A page is a tree of typed blocks. Every block has a kind and the fields of that kind. Container kinds have children, and text kinds have content. content is an array of runs. A run is text with optional marks, a mention of a resource, or a line break.
@legendkeeper/sdk/blocks exports one builder per kind, helpers for runs and marks, readers for what comes back, and the kind enums. The builders return plain objects, so you can also write the objects by hand.
import { LegendKeeper } from '@legendkeeper/sdk'
import {
bold, br, callout, code, collection, column, expand, findBlock, h, image, infobox, italic, keyValue, layout, li, link,
links, mention, meter, mono, ol, p, plainText, property, quote, rule, secret, table, task, tasks, td, th, tr, ul, walk,
} from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const flagPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64')
const flag = await lk.assets.uploadImage({ data: flagPng, contentType: 'image/png', name: 'The Candy Kingdom flag' })
const kingdom = await lk.resources.create({ name: 'Candy Kingdom' })
const pb = await lk.resources.create({ name: 'Princess Bubblegum' })
try {
const page = kingdom.documents[0]!
const doc = { resourceId: kingdom.id, documentId: page.id }
const written = await lk.documents.replaceBlocks(doc, {
blocks: [
infobox(
[
property(image(flag.url!, { alt: 'The Candy Kingdom flag', fill: true }), { title: 'Flag', isTitleHidden: true }),
property(keyValue([['Citizens', 'Candy People'], ['Founded', 'After the Mushroom War']]), { title: 'Facts' }),
property(links([pb.id]), { title: 'Ruler' }),
],
{ layout: 'wrapRight', width: 40 },
),
h(1, 'Candy Kingdom'),
p('A kingdom of candy people, ruled by ', mention({ resourceId: pb.id }), ', who is mostly science.'),
p('Guarded ', italic('badly'), ' by ', link('https://example.com/banana-guards', 'the Banana Guards'), '.', br, 'Their password: ', mono('I am a banana')),
callout('warning', ['Do not let the Earl of Lemongrab in. UNACCEPTABLE.']),
h(2, 'Rulers'),
collection({ items: [pb.id], isTitleHidden: true }),
h(2, 'Landmarks'),
ul('Candy Castle', li('Candy Village', ol(['Cinnamon Bun’s house', 'The Candy Kingdom Hospital']))),
tasks(task(true, 'Rebuild the castle after the Lich'), task(false, 'Tell the Candy People about the Lich')),
table(tr(th(['Kingdom']), th(['Ruler'])), tr(td(['Ice Kingdom']), td(['The Ice King'])), tr(td(['Fire Kingdom']), td(['Flame Princess']))),
layout(column(50, 'Exports: candy, science.'), column(50, 'Imports: nothing. She makes it.')),
expand('Where she came from', quote('A Candy Elemental, grown from the Mother Gum after the Mushroom War.')),
meter('bar', [{ name: 'Sugar reserves', icon: 'candy-cane', current: 6, max: 10, color: '#ff69b4' }]),
secret('Peppermint Butler knows dark magic.'),
code('bubblegum --analyze candy-person', 'bash'),
rule(),
],
})
console.log(`${written.blocks.length} top-level blocks at version ${written.version}`)
console.log(plainText(written) === written.text)
walk(written, (block, path) => console.log(path, block.kind))
const introId = written.blocks[2]?.id
if (!introId) throw new Error('the intro paragraph has an id')
console.log(findBlock(written, introId)?.kind)
} finally {
await lk.resources.delete({ resourceId: kingdom.id })
await lk.resources.delete({ resourceId: pb.id })
await lk.assets.delete({ assetId: flag.id })
}
The example uploads the flag with assets.uploadImage first, because an image takes only the url of an image asset of the project (see Image URLs).
documents.replaceBlocks replaces the whole tree and returns it with ids and the new version. documents.getBlocks reads it. Both return DocumentBlocks, which has these fields:
idandversion.schema, the block model version (1).blocks, the block tree.text, the document's plain text, with one line per text-bearing block.
Block ids are stable. A block written with an id keeps it, and a block written without one gets a new id. Reads always include ids. Use an id to address a block in blocks.get, blocks.update, blocks.replace, blocks.move and blocks.delete, and in the after, before and parent placement anchors.
The examples read block ids from the write response. documents.replaceBlocks returns the written tree with ids, and blocks.insert returns the inserted block with its id. To set a chosen id, use withId. Scripts that rebuild a known layout with documents.replaceBlocks use it to keep the same ids on every run.
Details:
- A mention reads as its
textlabel, or else its resource'sname.nameis read-only.
Builders
| Builder | Writes |
|---|---|
p(...text) |
A paragraph of runs. |
h(level, ...text) |
A heading at level 1 through 6. |
quote(first, ...rest) |
A block quote with at least one child. |
code(text, language?) |
A code block of plain text. |
rule() |
A horizontal rule. |
ul(first, ...rest) |
An unordered list of at least one item. A string, or an array of strings and runs such as ['Escort ', mention({ resourceId: pb.id }), ' home'], is a one-paragraph item. |
ol(items, { start? }) |
A numbered list of at least one item, starting at start. Items take the same forms as in ul. |
li(first, ...rest) |
One list item. first is inline content or a paragraph, and the rest can be nested lists. |
tasks(first, ...rest) |
A checklist of at least one task item or nested checklist. |
task(checked, ...text) |
One checklist entry. |
table(first, ...rest) |
A table of at least one row. |
tr(...cells) |
One table row. |
td(children, { colspan?, rowspan? }) |
One table cell. |
th(children, { colspan?, rowspan? }) |
One header cell. |
callout(tone, children, { icon?, color? }) |
A callout in a CalloutTone: info, note, success, warning, error or custom. The first five use the app's icon and color for that tone. custom uses icon (a Font Awesome name) and color (#RRGGBB). icon or color with any other tone returns 400 VALIDATION_ERROR. |
expand(title, ...children) |
A collapsible section. |
layout(first, second, third?) |
A row of two or three columns. |
column(width?, ...children) |
One layout column. A leading number is a percentage width. |
image(url, alt?) |
An image by the url of an image asset of the project (see Image URLs). The second argument is alt text or { alt?, fill?, title?, isTitleHidden?, layout?, width? }. fill: true draws the image edge to edge with no frame (see Block titles and image fill). |
embed(provider, url, { title?, isTitleHidden?, layout?, width? }) |
An embedded player from an EmbedProvider. Use a URL from that provider; the API accepts any http or https URL and does not check the host. |
keyValue(rows, { display?, title?, isTitleHidden?, layout?, width? }) |
Key-value rows, as [key, text] tuples or objects, drawn in the display variant. |
infobox(...properties) |
An infobox whose rows are PropertyBlocks. Pass the rows as an array to add { layout?, width? }. |
meter(mode, items, { showText?, showIcon?, showMax?, title?, isTitleHidden?, layout?, width? }) |
Tracked values drawn in a MeterMode. Each item has name, icon, current, max and an optional color: #RRGGBB, or none (the default) for the block tint. |
links(items, { title?, isTitleHidden?, layout?, width? }) |
A list of links. A string is a resource id. |
collection({ items?, filter?, title?, isTitleHidden?, layout?, width? }) |
A resource collection by items or filter. Give a collection with no meaningful name isTitleHidden: true. |
map({ resourceId, documentId }, { viewport?, displayHeight?, fill?, title?, isTitleHidden?, layout?, width? }) |
An interactive map of a map document of the project (see Maps in pages). |
secret(...children) |
Content hidden from players. Pass the children as an array to add { layout?, width? }. |
textField(...children) |
A rich-text container, used as a property body. Pass the children as an array to add { title?, isTitleHidden?, layout?, width? }. |
withId(id, block) |
The same block with an explicit id. |
property(block, envelope?) |
A PropertyBlock: a property-capable block plus title, isTitleHidden and isHidden. The envelope defaults to {}. |
A string in a children argument is shorthand for a paragraph. Builders with an options bag take children as an array, and the others take them as trailing arguments. If a kind requires children, its builder takes the first child as a required argument, so the empty form does not compile.
Layout and width
The kinds the editor lays out (image, embed, keyValue, meter, resourceCollection, resourceLink, map, secret, textField and infobox) have layout and width. layout is one of default, wrapLeft, wrapRight, center, alignStart and alignEnd. wrapLeft and wrapRight float the block so the blocks after it wrap beside it. width is a percentage of the column, 10 to 100, and applies when layout is not default.
A new infobox floats right at width 40, and every other kind is full width. On a replace or an update, an omitted field keeps its current value. You can write back a block you read without losing the layout it has in the editor.
A block's title and an image's fill also change how the block is drawn. See Block titles and image fill.
Block titles and image fill
The kinds the app draws with a label above them (image, embed, keyValue, meter, resourceCollection, resourceLink, map and textField) have title and isTitleHidden. title is the label, up to 256 characters. isTitleHidden: true hides the label and the frame around the block, like "Hide title" in the app. secret and infobox have neither field.
A block written without these fields gets its kind's defaults:
| Kind | Default title |
Default isTitleHidden |
|---|---|---|
image |
Image |
true |
embed |
Embed |
true |
keyValue |
Stats |
false |
meter |
Meter |
false |
resourceCollection |
Collection |
false |
resourceLink |
Links |
false |
map |
Map |
false |
textField |
Text |
false |
Hide a collection's title unless the title names the collection and does not repeat the heading above it. The example hides the title of the collection under the "Residents" heading and gives the second collection its own title. It then shows the first collection's title with blocks.update:
import { LegendKeeper } from '@legendkeeper/sdk'
import { collection, h, image, infobox, p, property } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const portraitPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64')
const portrait = await lk.assets.uploadImage({ data: portraitPng, contentType: 'image/png', name: 'The Tree Fort' })
const treeFort = await lk.resources.create({ name: 'The Tree Fort' })
const finn = await lk.resources.create({ name: 'Finn the Human' })
const jake = await lk.resources.create({ name: 'Jake the Dog' })
try {
const doc = { resourceId: treeFort.id, documentId: treeFort.documents[0]!.id }
const written = await lk.documents.replaceBlocks(doc, {
blocks: [
infobox(property(image(portrait.url!, { alt: 'The Tree Fort', fill: true }), { title: 'Portrait', isTitleHidden: true })),
p('A hollow tree in the Grasslands, home to two heroes.'),
h(2, 'Residents'),
collection({ items: [finn.id, jake.id], isTitleHidden: true }),
collection({ items: [jake.id], title: 'Can stretch', isTitleHidden: false }),
],
})
for (const block of written.blocks) {
if (block.kind === 'resourceCollection') console.log(block.title, block.isTitleHidden)
}
const residents = written.blocks[3]
if (!residents?.id) throw new Error('the residents collection has an id')
const shown = await lk.blocks.update({ ...doc, blockId: residents.id }, { title: 'Everyone', isTitleHidden: false, ifMatch: written.version })
if (shown.kind === 'resourceCollection') console.log(shown.title, shown.isTitleHidden)
} finally {
await lk.resources.delete({ resourceId: treeFort.id })
await lk.resources.delete({ resourceId: finn.id })
await lk.resources.delete({ resourceId: jake.id })
await lk.assets.delete({ assetId: portrait.id })
}
Set fill: true on an image to draw it edge to edge with no frame or title, like "Fill block" in the app. fill applies while isTitleHidden is true, which is the default for a new image.
Inside a property, title and isTitleHidden are fields of the property envelope, and an image property takes fill the same way. For example, a portrait at the top of an infobox is property(image(url, { fill: true }), { title: 'Portrait', isTitleHidden: true }).
Details:
fillhas no default. A replace that omitsfillremoves it, as it doesalt.
Runs and marks
| Helper | Writes |
|---|---|
runs(text) |
Normalizes a string, a run or a nested array into a run array. |
bold(...text) |
Bold text. |
italic(...text) |
Italic text. |
underline(...text) |
Underlined text. |
strike(...text) |
Struck-through text. |
mono(...text) |
Inline code. It clears every other mark, and later marks skip a mono run. |
sub(...text) |
Subscript. It clears sup. |
sup(...text) |
Superscript. It clears sub. |
link(href, ...text) |
Linked text. |
mention({ resourceId, documentId?, text? }) |
A run array with one mention run, like the other run helpers. |
br |
A line break run. |
Mark helpers nest: bold(italic('Mathematical!')) is bold italic text. Text arguments accept strings, runs and arrays of either, so p('What time is it? ', bold('Adventure time'), '!') needs no wrapping.
Readers
| Reader | Returns |
|---|---|
plainText(input) |
The text of a block, a list of blocks or a document, derived the same way as DocumentBlocks.text. |
walk(input, visitor) |
Calls the visitor for every block with its dotted path, such as blocks[0].children[1]. It descends into children and infobox rows. |
findBlock(input, id) |
The first block with that id, or undefined. |
Enums
BlockKind, BlockLayout, CalloutTone, EmbedProvider, KeyValueDisplay, MeterMode and PropertyKind are as const objects with matching union types. Use them for readable comparisons, such as block.kind === BlockKind.heading.
Block operations
The single-block operations read and change one block and its subtree by id.
import { LegendKeeper } from '@legendkeeper/sdk'
import { bold, h, p, runs, ul } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const fort = await lk.resources.create({ name: 'The Tree Fort' })
try {
const page = fort.documents[0]!
const doc = { resourceId: fort.id, documentId: page.id }
const written = await lk.documents.replaceBlocks(doc, { blocks: [h(1, 'The Tree Fort'), ul('Treasure room', 'BMO’s charging nook')] })
const [titleId, roomsId] = written.blocks.map((block) => block.id)
if (!titleId || !roomsId) throw new Error('the written blocks have ids')
let { version } = written
const intro = await lk.blocks.insert(doc, { block: p('Where Finn and Jake live.'), after: titleId, ifMatch: version })
if (!intro.id) throw new Error('the inserted block has an id')
const introId = intro.id
version = intro.version
const updated = await lk.blocks.update(
{ ...doc, blockId: introId },
{ content: runs(['Where Finn and Jake live, with ', bold('BMO'), '.']), ifMatch: version },
)
version = updated.version
const replaced = await lk.blocks.replace({ ...doc, blockId: titleId }, { ...h(2, 'The Tree Fort, home of heroes'), ifMatch: version })
version = replaced.version
const moved = await lk.blocks.move({ ...doc, blockId: roomsId }, { before: introId, ifMatch: version })
version = moved.version
const rooms = await lk.blocks.get({ ...doc, blockId: roomsId })
console.log(rooms.kind, rooms.version === version)
await lk.blocks.delete({ ...doc, blockId: roomsId }, { ifMatch: version })
const { assigned } = await lk.documents.assignBlockIds(doc)
console.log(`${assigned} blocks lacked an id`)
const final = await lk.documents.getBlocks(doc)
console.log(final.blocks.map((block) => `${block.id}: ${block.kind}`).join('\n'))
} finally {
await lk.resources.delete({ resourceId: fort.id })
}
| Operation | Does |
|---|---|
blocks.insert |
Inserts block after or before an anchor block, or as the last child of a parent container. With no anchor, it appends to the end of the document. An id in the block is kept. |
blocks.get |
Returns the block with its subtree. |
blocks.update |
Changes the block's own fields, such as content, level, checked, tone, layout or width. Children stay as they are. |
blocks.replace |
Replaces the block and its subtree. The block keeps its id, and ids from the old subtree are kept when echoed. |
blocks.move |
Moves the block and its subtree to exactly one of after, before or parent. Ids are kept. |
blocks.delete |
Removes the block and its subtree. |
documents.assignBlockIds |
Gives an id to every block that lacks one, and returns the count assigned and the new version. Documents written in the app before block ids existed lack them. Run this once before you address their blocks. |
Rejected writes
Every block write validates the whole resulting document. A rejected write changes nothing and returns one of the codes below. Its errors array has a path for the offending block, in the same dotted form that walk reports.
| Code | Meaning |
|---|---|
VALIDATION_ERROR |
The block does not match the schema. For example: an unknown kind, an unknown field, a missing required field, or a rank in a property create body. |
UNSUPPORTED_KIND |
The write has kind: "unsupported" for an id the document does not hold. |
INVALID_CONTENT |
The block is well formed, but the editor cannot hold it there. For example, a listItem at the top level, or a list inside an expand inside a table cell. |
KIND_MISMATCH |
The document is not a page, or a row operation targets a block that is not a keyValue. Returns 400 with no errors array. |
DOCUMENT_TOO_LARGE |
The resulting document would exceed the document size limit. Returns 422. |
If you write an array where a block belongs, such as runs as the direct child of a listItem, the write fails with VALIDATION_ERROR at that path. The message is Expected a block object such as { "kind": "paragraph", "content": [{ "text": "..." }] }, received an array. Wrap the runs in a paragraph, or pass them to li, ul or ol, which wrap them for you.
Image URLs
Documents hold references to images, not image data. Upload each image with assets.uploadImage (see assets), and write the asset's url into the image field. An image field accepts only the url of an image asset of the same project.
The image fields are image blocks, image properties, banner.url, feature pinImage, lane style.imageUrl, event imageUrl and the src of a board image asset. Any other URL in an image field, including an image asset of another project, returns 400 VALIDATION_ERROR. A URL that the same document, resource or board already holds in an image field is accepted again. errors[].path identifies the field.
On every write to a project, a data: or blob: URI fails with 400 VALIDATION_ERROR in any field named url, src or href, or ending in Url or Src. errors[].path identifies the field, such as blocks[0].url, and nothing is written.
Details:
- Board writes refuse the same URIs in image, video and bookmark assets and in a shape
url. See boards. - Links, link marks, bookmarks and embed blocks are not images. Link marks take
http,https,mailtoandlkURLs; embed blocks takehttpandhttpsURLs.
Maps in pages
A map block or property draws a map document of the project, identified by resourceId and documentId. Set both ids, or set neither for an empty block that the app shows as "Link a map".
viewport is { center: [lat, lng], zoom } in the map's coordinate frame, which is the frame of a feature's pos. Omit viewport to center the map on a pin whose link points to the resource holding the map. For example, the pin for a city centers the map on the city's own page.
Unsupported blocks
A block that the public model cannot express reads as { kind: 'unsupported', id }. It is read-only. To keep its content, echo it by id in documents.replaceBlocks, and the server restores the original content in place. If you leave it out, that content is deleted.
To change a document without touching content you cannot read, use the single-block operations instead of documents.replaceBlocks.
Key-value rows
Key-value data lives in three places: a keyValue block in a page, a sidebar property of kind keyValue, and a keyValue property inside an infobox. All three use the same row shape.
import { LegendKeeper } from '@legendkeeper/sdk'
import { bold, br, keyValue } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const bmo = await lk.resources.create({ name: 'BMO' })
try {
const page = bmo.documents[0]!
const written = await lk.documents.replaceBlocks(
{ resourceId: bmo.id, documentId: page.id },
{ blocks: [keyValue([['Lives in', 'The Tree Fort'], ['Plays', 'Video games']])] },
)
const specsId = written.blocks[0]?.id
if (!specsId) throw new Error('the keyValue block has an id')
const specs = { resourceId: bmo.id, documentId: page.id, blockId: specsId }
const rows = await lk.blocks.listRows(specs)
const plays = rows.data.find((row) => row.key === 'Plays')
if (!plays?.id) throw new Error('the Plays row has no id')
const friend = await lk.blocks.createRow(specs, {
key: 'Best friend',
content: [...bold('Football'), br, { text: 'the reflection in the bathroom mirror' }],
after: plays.id,
})
if (!friend.id) throw new Error('the new row has no id')
console.log(friend.key, JSON.stringify(friend.text))
await lk.blocks.updateRow({ ...specs, rowId: plays.id }, { text: 'Guardians of Sunshine' })
await lk.blocks.deleteRow({ ...specs, rowId: friend.id })
for (const row of (await lk.blocks.listRows(specs)).data) console.log(`${row.key}: ${row.text}`)
} finally {
await lk.resources.delete({ resourceId: bmo.id })
}
Each row has id, rank, key, content (runs) and text, the plain text of content. Rows are ordered by rank.
| Container | List | Create | Update | Delete |
|---|---|---|---|---|
keyValue block |
blocks.listRows |
blocks.createRow |
blocks.updateRow |
blocks.deleteRow |
| Sidebar property | properties.listRows |
properties.createRow |
properties.updateRow |
properties.deleteRow |
| Infobox property | blocks.listPropertyRows |
blocks.createPropertyRow |
blocks.updatePropertyRow |
blocks.deletePropertyRow |
To create a row, pass key and a value, either as content runs or as a plain text string. To place the row, pass before or after with another row's id. With neither, the row is appended. To update a row, pass any of key, content and text.
Details:
- A stored line break reads back as
\nintext.
Infoboxes
An infobox is a block whose rows are properties, with the same shape as sidebar properties.
import { LegendKeeper } from '@legendkeeper/sdk'
import { infobox, keyValue, p, property, textField } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const jake = await lk.resources.create({ name: 'Jake the Dog' })
try {
const page = jake.documents[0]!
const written = await lk.documents.replaceBlocks(
{ resourceId: jake.id, documentId: page.id },
{
blocks: [
infobox(property(keyValue([['Species', 'Magic dog'], ['Age', '28']]), { title: 'Stats' })),
p('Finn’s brother. Stretches into anything, including a boat, a key and a very large hand.'),
],
},
)
const box = written.blocks[0]
if (box?.kind !== 'infobox') throw new Error('the first block is the infobox')
const statsId = box.rows[0]?.id
if (!box.id || !statsId) throw new Error('the infobox and its Stats property have ids')
const sheet = { resourceId: jake.id, documentId: page.id, blockId: box.id }
const bio = await lk.blocks.createProperty(sheet, {
...property(textField('Got his powers from rolling in a magic mud puddle.'), { title: 'Origin' }),
before: statsId,
ifMatch: written.version,
})
if (!bio.id) throw new Error('the new property has no id')
console.log(bio.id, bio.kind, bio.rank)
await lk.blocks.updateProperty({ ...sheet, propertyId: bio.id }, { title: 'Powers', isTitleHidden: false })
console.log((await lk.blocks.getProperty({ ...sheet, propertyId: bio.id })).title)
const stats = { ...sheet, propertyId: statsId }
const plays = await lk.blocks.createPropertyRow(stats, { key: 'Plays', text: 'viola' })
if (!plays.id) throw new Error('the new row has no id')
await lk.blocks.updatePropertyRow({ ...stats, rowId: plays.id }, { text: 'viola, badly, for Lady Rainicorn' })
for (const row of (await lk.blocks.listPropertyRows(stats)).data) console.log(`${row.key}: ${row.text}`)
await lk.blocks.deletePropertyRow({ ...stats, rowId: plays.id })
await lk.blocks.deleteProperty({ ...sheet, propertyId: bio.id })
for (const item of (await lk.blocks.listProperties(sheet)).data) console.log(item.id, item.kind, item.title)
} finally {
await lk.resources.delete({ resourceId: jake.id })
}
Address an infobox's rows by the infobox's blockId with blocks.listProperties, blocks.createProperty, blocks.getProperty, blocks.updateProperty and blocks.deleteProperty. These take the same bodies as the sidebar's properties.* operations and return the document version.
Any property-capable kind can be an infobox row. For example, a city's infobox can hold property(map({ resourceId: atlas.id, documentId: atlasMap.id }), { title: 'Location' }). Without a viewport, the map opens centered on the city's pin (see Maps in pages).
Collection filters
collection({ filter }) selects resources with a filter predicate, the one the app's collection source picker writes, instead of a fixed items list. The app evaluates the predicate each time it draws the collection, so the collection follows later changes to the project. The API only stores and returns it.
import { LegendKeeper } from '@legendkeeper/sdk'
import { collection, h, p } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const kingdom = await lk.resources.create({ name: 'Candy Kingdom', tags: ['Location', 'Candy Kingdom'] })
const residents = []
for (const resident of [
{ name: 'Princess Bubblegum', tags: ['NPC', 'Candy Kingdom', 'Ally'] },
{ name: 'Peppermint Butler', tags: ['NPC', 'Candy Kingdom', 'Ally'] },
{ name: 'Earl of Lemongrab', tags: ['NPC', 'Candy Kingdom', 'Villain'] },
]) {
residents.push(await lk.resources.create({ ...resident, parentId: kingdom.id }))
}
try {
const written = await lk.documents.replaceBlocks(
{ resourceId: kingdom.id, documentId: kingdom.documents[0]!.id },
{
blocks: [
p('The candy-coated capital of Ooo.'),
h(2, 'Residents'),
collection({
filter: { type: 'filter', filterType: 'field', id: 'residents', field: 'parentId', operator: 'equals', value: '$self' },
isTitleHidden: true,
}),
h(2, 'Allies'),
collection({
filter: { type: 'filter', filterType: 'tag', id: 'allies', operator: 'has_all', tags: ['NPC', 'Ally'] },
isTitleHidden: true,
}),
],
},
)
for (const block of written.blocks) {
if (block.kind === 'resourceCollection') console.log(block.filter?.filterType, block.isTitleHidden)
}
} finally {
for (const resident of residents) await lk.resources.delete({ resourceId: resident.id })
await lk.resources.delete({ resourceId: kingdom.id })
}
Every predicate has type: 'filter', an id and a filterType. The id is any string and does not affect matching. Wherever a value is a resource id, '$self' stands for the resource whose document holds the collection. The first collection above therefore lists the Candy Kingdom's children wherever the page is copied.
filterType |
Fields | Selects |
|---|---|---|
field |
field, operator, value |
Resources whose field compares true against value. field is parentId (the resource's parent id) or name. |
field with scope: 'edges' |
field: 'targetId', operator: 'equals', value |
Resources whose documents, properties or map pins link to the resource value: its backlinks. |
tag |
operator, tags |
has_all: resources with every tag in tags. has_any: at least one. has_none: none of them. Tags match exactly, including case. has_all with an empty tags selects nothing. |
property |
propertyType, key, field, operator, value |
Resources with a key-value row (propertyType: 'KEY_VALUE', key is the row's key) or a meter item (propertyType: 'METER', key is the item's name) whose value compares true. field is textValue or numValue for a row, and meterCurrent or meterMax for a meter item. If omitted, it is numValue for a number value and textValue otherwise. |
mention |
targetId, propertyType, key |
Resources with a key-value row that mentions the resource targetId, narrowed by propertyType and key when given. |
text |
operator: 'contains', value |
Resources that the app's full-text search matches for value. |
composite |
operator, filters |
and: resources every predicate in filters selects. or: resources any of them selects. not: every resource the single predicate in filters does not select. |
field and property take these operators:
equals,not_equals,greater_than,greater_than_or_equal,less_than,less_than_or_equal.contains(case-insensitive).starts_withandends_with(case-sensitive).inandnot_in, with an arrayvalue.regex, onpropertyonly.
Details:
- Deleted resources and templates are never listed.
- The schema accepts the filter types
id,customanddate_range, but the app does not evaluate them, so a collection that uses one lists no resources. - A collection whose filter is a
parentIdequality, alone or inside anand, lists its resources in tree order. Every other collection lists them by name.
Namespaces and operations
Every operation is lk.<namespace>.<method>. The tables below list the operations of each namespace, and the API reference documents every field. Timestamps such as createdAt and updatedAt are ISO 8601 strings in UTC ending in Z.
resources
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo', iconGlyph: 'globe', iconColor: '#4caf50' })
try {
const kingdom = await lk.resources.create({
name: 'Ice Kingdom',
parentId: ooo.id,
tags: ['Kingdom', 'Cold'],
aliases: ['Land of Ice'],
})
console.log(kingdom.id, kingdom.parentId, kingdom.tags, kingdom.documents.length, kingdom.properties.length)
const renamed = await lk.resources.update({ resourceId: kingdom.id }, { name: 'The Ice Kingdom', tags: ['Kingdom'] })
console.log(renamed.name, renamed.tags, renamed.aliases)
const fetched = await lk.resources.get({ resourceId: kingdom.id })
console.log(fetched.name, fetched.rank)
for await (const child of await lk.resources.list({ parentId: ooo.id })) console.log('child:', child.name, JSON.stringify(child.aliases))
await lk.resources.delete({ resourceId: kingdom.id })
} finally {
await lk.resources.delete({ resourceId: ooo.id })
}
A resource is an entry in the project tree, such as a character, a place or a faction. It holds documents and sidebar properties.
A new resource has one blank document named Main, returned as documents[0]. Your first content write gives it a type: blocks make it a page, features or a map image make it a map, board records make it a board, and a lane makes it a timeline. See documents for each write. The examples in this guide write to documents[0] and call documents.create only for additional documents.
| Operation | Does |
|---|---|
resources.list |
A page of the project's resources as list items. A list item has id, name, parentId, tags, aliases, the icon fields, rank, isHidden, timestamps and documents, each document's id, name and type. Pass parentId to list the direct children of one resource. Paginated. |
resources.createMany |
Creates up to 100 resources in one call and returns them in order. See Creating many resources. |
resources.createFromTemplate |
Creates a resource as a copy of a template and returns it. See templates. |
resources.saveAsTemplate |
Creates a top-level template as a copy of the resource and returns it. The resource does not change. ADMIN role. See templates. |
resources.create |
Creates a resource and returns it. name is required. See Stable ids and tree order for id, after and before, and Documents with content for documents. |
resources.get |
Returns the resource with its documents and sidebar properties. |
resources.update |
Changes only the fields you send: any of name, tags, aliases, iconGlyph, iconColor, iconShape, iconSize, isHidden, isLocked, banner and permissions. |
resources.move |
Moves the resource and its children to another parent, another position among its siblings, or both. Returns the resource. |
resources.delete |
Deletes the resource with its documents and properties. Set cascade to true to delete its child resources with it. |
resources.restore |
Restores a deleted resource and the descendants deleted with it. Returns the resource. |
iconGlyph is a Font Awesome icon name, such as crown or dog. iconColor is #RRGGBB. iconShape and iconSize set the shape drawn behind the glyph when the resource appears as a pin on a map.
aliases are alternative names that search matches. On update, tags and aliases replace the whole list.
Creating many resources
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const newId = () => `r${crypto.randomUUID().replaceAll('-', '').slice(0, 23)}`
const ooo = newId()
const { resources } = await lk.resources.createMany({
resources: [
{ id: ooo, name: 'The Land of Ooo', tags: ['World'] },
{ id: newId(), name: 'Candy Kingdom', parentId: ooo, tags: ['Kingdom'] },
{ id: newId(), name: 'Ice Kingdom', parentId: ooo, tags: ['Kingdom'] },
{
id: newId(),
name: 'Fire Kingdom',
parentId: ooo,
tags: ['Kingdom'],
documents: [{ blocks: [{ kind: 'paragraph', content: [{ text: 'Ruled by Flame Princess.' }] }] }],
},
],
})
try {
for (const resource of resources) console.log(resource.name, resource.parentId === ooo ? 'in Ooo' : 'at the root')
} finally {
await lk.resources.delete({ resourceId: ooo }, { cascade: true })
}
resources.createMany takes resources, 1 to 100 items, each the same object resources.create takes. The server checks every item before it creates any. If an item fails, the call throws that item's error, every errors[].path starts with resources[i], and nothing is created. The response lists the created resources in the order you sent them.
An item can name an earlier item of the same call as its parentId when that earlier item has an id. Such an item is placed after its earlier siblings in the call; it cannot use after or before. With these, one call builds a whole subtree.
Give every item an id. If the call fails with a server error part-way, the items created before the failure remain, and a retry returns 409 DUPLICATE_ID for those instead of creating them twice. See Stable ids and tree order.
Details:
- A call counts as one write request against the rate limits. Its items also count against a budget of 600 items a minute and 6,000 an hour; a call past the budget returns 429
RATE_LIMITED. - An
idused twice in one call, or already used in the project, returns 409DUPLICATE_ID.
Deleting and restoring
import { ConflictError, LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
const candy = await lk.resources.create({ name: 'Candy Kingdom', parentId: ooo.id })
await lk.resources.create({ name: 'Candy Castle', parentId: candy.id })
try {
await lk.resources.delete({ resourceId: ooo.id })
} catch (error) {
if (!(error instanceof ConflictError) || error.code !== 'HAS_CHILDREN') throw error
console.log(`${error.code}: ${error.message}`)
}
await lk.resources.delete({ resourceId: ooo.id }, { cascade: true })
const restored = await lk.resources.restore({ resourceId: ooo.id })
const { data: children } = await lk.resources.list({ parentId: ooo.id })
console.log(restored.name, children.map((child) => child.name).join(', '))
await lk.resources.delete({ resourceId: ooo.id }, { cascade: true })
resources.delete fails with 409 HAS_CHILDREN, thrown as ConflictError, when the resource has child resources that are not deleted. Pass { cascade: true } to delete the resource and all its descendants together.
resources.restore restores the resource and every descendant that the same delete removed. Restoring needs the admin role. The API does not list deleted resources, so keep the id of any resource you may want to restore.
Details:
- A key that acts as a member needs Can Edit or Full Access on the resource and on every descendant of a cascade, or the delete fails with 403
FORBIDDENand deletes nothing.
Documents with content
import { LegendKeeper } from '@legendkeeper/sdk'
import { bold, h, p, plainText, ul } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const kingdom = await lk.resources.create({
name: 'Candy Kingdom',
documents: [
{
name: 'Overview',
blocks: [
h(1, 'Candy Kingdom'),
p('Ruled by ', bold('Princess Bubblegum'), '.'),
ul('Candy Castle', 'Candy Village'),
],
},
{ name: 'Map', type: 'map' },
],
})
try {
const [overview, map] = kingdom.documents
console.log(overview!.name, overview!.type, map!.name, map!.type)
console.log(plainText(await lk.documents.getBlocks({ resourceId: kingdom.id, documentId: overview!.id })))
} finally {
await lk.resources.delete({ resourceId: kingdom.id })
}
Pass documents to resources.create to create the resource with up to 5 documents, in order. Each sets type, name, blocks (page only) and calendarId (timeline only). blocks is validated as documents.replaceBlocks validates it. The response has no blocks. Read them with documents.getBlocks.
Details:
- One call creates the resource and its content, and counts as one write against the rate limits.
Stable ids and tree order
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const newId = () => `r${crypto.randomUUID().replaceAll('-', '').slice(0, 23)}`
const ids = { ooo: newId(), candy: newId(), ice: newId(), fire: newId() }
async function kingdomsInOrder(parentId: string): Promise<string> {
const { data } = await lk.resources.list({ parentId })
return data
.sort((a, b) => ((a.rank ?? '') < (b.rank ?? '') ? -1 : 1))
.map((kingdom) => kingdom.name)
.join(', ')
}
const ooo = await lk.resources.create({ id: ids.ooo, name: 'The Land of Ooo' })
const created: string[] = []
try {
created.push((await lk.resources.create({ id: ids.candy, name: 'Candy Kingdom', parentId: ooo.id })).id)
created.push((await lk.resources.create({ id: ids.fire, name: 'Fire Kingdom', parentId: ooo.id })).id)
created.push((await lk.resources.create({ id: ids.ice, name: 'Ice Kingdom', parentId: ooo.id, before: ids.fire })).id)
console.log(await kingdomsInOrder(ooo.id))
await lk.resources.move({ resourceId: ids.fire }, { after: ids.candy })
console.log(await kingdomsInOrder(ooo.id))
const ice = await lk.resources.move({ resourceId: ids.ice }, { parentId: null })
console.log(ice.name, ice.parentId ?? 'at the root')
const candy = await lk.resources.get({ resourceId: ids.candy })
console.log(candy.id === ids.candy, candy.name)
} finally {
for (const id of created) await lk.resources.delete({ resourceId: id })
await lk.resources.delete({ resourceId: ooo.id })
}
To sync from another system, pass your own id to resources.create. Generate an id once per source record and store it with the record. On the next run, read the resource with resources.get instead of matching by name. The id must be a cuid2 id of 8 or 24 characters. The example generates its ids with crypto.randomUUID().
A new resource goes after its last sibling. To place it next to a sibling, pass that sibling's id as after or before.
resources.move takes the resource id, then any of parentId (a resource id, or null for the root), after and before.
rank orders siblings. To show the app's order, sort siblings by rank as plain strings, not with a locale comparison, as kingdomsInOrder does.
Details:
- An id already used by a resource of the project, including a deleted one, fails with 409
DUPLICATE_ID, thrown asConflictError.
templates
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const kingdom = await lk.resources.create({
name: 'Kingdom',
tags: ['Kingdom'],
documents: [{ blocks: [{ kind: 'paragraph', content: [{ text: 'Ruler, capital, allies.' }] }] }],
})
try {
await lk.resources.create({ name: 'Castle', parentId: kingdom.id })
const template = await lk.resources.saveAsTemplate({ resourceId: kingdom.id }, { name: 'Kingdom Template', includeChildren: true })
try {
for await (const listed of await lk.templates.list()) console.log('template:', listed.name)
const realm = await lk.resources.createFromTemplate({ templateId: template.id, name: 'Realm', includeChildren: true })
try {
const page = await lk.documents.getBlocks({ resourceId: realm.id, documentId: realm.documents[0]!.id })
console.log(realm.name, realm.tags, page.text)
for await (const child of await lk.resources.list({ parentId: realm.id })) console.log('child:', child.name)
} finally {
await lk.resources.delete({ resourceId: realm.id }, { cascade: true })
}
} finally {
await lk.resources.delete({ resourceId: template.id }, { cascade: true })
}
} finally {
await lk.resources.delete({ resourceId: kingdom.id }, { cascade: true })
}
A template is a resource under the virtual root templates. templates is a reserved id: no resource has it, and resources.get does not return it. A top-level template has parentId templates. A resource under a template is a template too, and its parentId is that template's id.
| Operation | Does |
|---|---|
templates.list |
A page of the project's templates as list items, at any depth. A top-level template has parentId templates; a nested one has its parent template's id. Paginated. |
Two resources operations copy to and from templates:
resources.saveAsTemplatecopies a resource into a new top-level template.namedefaults to the resource's name.includeChildren: truealso copies its descendants, keeping their tree.after,beforeandidwork as inresources.create. The resource does not change.resources.createFromTemplatecopies a template into a new resource.templateIdnames a template at any depth; a resource that is not a template returns 400VALIDATION_ERROR.namedefaults to the template's name without the wordTemplate.parentId,after,before,idandincludeChildrenwork as above.
A copy has the source's fields, sidebar properties and documents with their content, under new ids. A copy holds at most 100 resources, and each copied resource counts against the item budget of resources.createMany. Mentions in the copied content keep pointing at the resources they named in the source.
Templates are read, updated, moved and deleted with the resources operations, which also take the templates id:
resources.createwithparentId: 'templates'creates a top-level template.resources.movewithparentId: 'templates'makes a resource a top-level template.parentId: nullmoves it back to the root.resources.listwithparentId: 'templates'lists the top-level templates. WithoutparentId, it lists templates with the other resources.
Only a project admin can create a resource under templates, move one there, or call resources.saveAsTemplate. Search and collection blocks do not return templates.
documents
import { LegendKeeper } from '@legendkeeper/sdk'
import { p } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const fort = await lk.resources.create({ name: 'The Tree Fort' })
try {
const first = fort.documents[0]
if (!first) throw new Error('a new resource has one document')
const overview = { resourceId: fort.id, documentId: first.id }
console.log(first.name, first.type)
await lk.documents.replaceBlocks(overview, { blocks: [p('A giant tree full of treasure.')] })
const content = await lk.documents.getBlocks(overview)
console.log(content.schema, content.version, JSON.stringify(content.text))
await lk.documents.update(overview, { name: 'About the Tree Fort', isFullWidth: true })
const about = await lk.documents.get(overview)
console.log(about.name, about.type, about.isFullWidth, about.rank)
const stash = await lk.documents.create({ resourceId: fort.id }, { name: 'Jake’s secret stash', type: 'page', isHidden: true })
const floorPlan = await lk.documents.create({ resourceId: fort.id }, { name: 'Floor plan', type: 'map' })
const listed = await lk.documents.list({ resourceId: fort.id })
console.log(listed.data.map((doc) => `${doc.name} (${doc.type})`).join(', '), 'hides', stash.name)
await lk.documents.delete({ resourceId: fort.id, documentId: floorPlan.id })
} finally {
await lk.resources.delete({ resourceId: fort.id })
}
A document is one tab of a resource. Its type sets what it holds: page holds blocks, map holds features, time holds a timeline and board holds a drawing canvas. A blank document holds nothing yet.
A new resource's first document is blank. Write content to it, and call documents.create only for additional documents, as the example does.
Your first typed write to a blank document converts it in the same request that saves the content:
| Write | Converts it to |
|---|---|
documents.replaceBlocks, blocks.insert |
page |
boards.createShapes, boards.replaceContent |
board |
features.create, documents.update with mapImage |
map |
timelines.createLane |
time, with calendarId gregorian |
Once converted, the document's type never changes back.
| Operation | Does |
|---|---|
documents.list |
Every document of the resource as a summary. Hidden documents are listed only to the ADMIN and OWNER roles. Order by rank to match the app. |
documents.create |
Creates a document after the existing ones. name and type (page, board, map, time or blank) are required; isHidden and isFullWidth default to false; a time document may take calendarId. It starts empty. |
documents.get |
The document summary: id, name, type, rank, isHidden, isFullWidth, timestamps, calendarId and sources for a timeline, and mapImage for a map. |
documents.update |
Changes any of name, rank, isHidden, isFullWidth, calendarId and sources on a time document (see Chaining timelines), and mapImage on a map document (see Map images). Content is not touched. |
documents.delete |
Deletes the document. Its content is kept in the project's trash until documents.restore brings it back or the trash is emptied. |
documents.restore |
Brings a deleted document back in its old place, with its content. A document that is not deleted answers 409 NOT_DELETED; one whose resource is deleted answers 409 PARENT_DELETED (restore the resource instead). Needs the same access as deleting it. |
documents.getBlocks |
The page content as blocks, with text and version. See Writing content. |
documents.replaceBlocks |
Replaces the page content. Takes ifMatch. |
documents.assignBlockIds |
Gives an id to every block that has none. See Block operations. |
Read and write board content with boards.
Details:
- Reads never convert a
blankdocument:documents.getBlocksreturns an empty page,boards.getContentandboards.listShapesreturn an empty board, andfeatures.list,timelines.listLanesandtimelines.listEventsreturn empty lists.
properties
import { LegendKeeper } from '@legendkeeper/sdk'
import { keyValue, property, textField } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const marceline = await lk.resources.create({ name: 'Marceline the Vampire Queen' })
try {
const sidebar = { resourceId: marceline.id }
const stats = await lk.properties.create(sidebar, property(keyValue([['Age', 'Over a thousand']]), { title: 'Stats' }))
const bio = await lk.properties.create(sidebar, {
...property(textField('Eats the color red. Plays an axe bass that used to be her family’s heirloom axe.'), { title: 'Bio' }),
before: stats.id,
})
if (!stats.id || !bio.id) throw new Error('a new property has no id')
await lk.properties.update({ ...sidebar, propertyId: bio.id }, { title: 'Lore', isHidden: true })
const lore = await lk.properties.get({ ...sidebar, propertyId: bio.id })
console.log(lore.title, lore.isHidden)
const rows = { ...sidebar, propertyId: stats.id }
const dad = await lk.properties.createRow(rows, { key: 'Dad', text: 'Hunson Abadeer' })
if (!dad.id) throw new Error('the new row has no id')
await lk.properties.updateRow({ ...rows, rowId: dad.id }, { key: 'Father' })
for (const row of (await lk.properties.listRows(rows)).data) console.log(`${row.key}: ${row.text}`)
await lk.properties.deleteRow({ ...rows, rowId: dad.id })
await lk.properties.delete({ ...sidebar, propertyId: bio.id })
for (const item of (await lk.properties.list(sidebar)).data) console.log(item.kind, item.title, item.rank)
} finally {
await lk.resources.delete({ resourceId: marceline.id })
}
Sidebar properties are the fields beside a resource's pages. Each is a PropertyBlock: a property-capable block plus the envelope fields id, rank, title, isTitleHidden and isHidden. The property-capable blocks are textField, keyValue, image, meter, resourceCollection, resourceLink, embed and map. Build a property with property(block, envelope).
| Operation | Does |
|---|---|
properties.list |
Returns the sidebar properties, sorted by rank. |
properties.create |
Adds a property. To place it, pass another property's id as before or after. Pass neither to append it. |
properties.get |
Returns one property. |
properties.update |
Merges the fields you send into the property: envelope fields and the fields of its kind. Patching rank moves it. |
properties.delete |
Removes the property and its rows. |
properties.listRows |
Returns the rows of a keyValue property. |
properties.createRow |
Adds a row. Takes ifMatch. |
properties.updateRow |
Changes a row. Takes ifMatch. |
properties.deleteRow |
Removes a row. Takes ifMatch. |
blocks
Use blocks to work with single blocks in a page, the rows of a keyValue block, and the properties and rows of an infobox. Every write takes ifMatch and returns the document version. For examples, see Writing content.
| Operation | Does |
|---|---|
blocks.insert |
Inserts a block. |
blocks.get |
Reads a block and its subtree. |
blocks.update |
Changes a block's own fields. |
blocks.replace |
Replaces a block and its subtree. |
blocks.move |
Moves a block and its subtree. |
blocks.delete |
Deletes a block and its subtree. |
blocks.listRows |
Returns the rows of a keyValue block. |
blocks.createRow |
Adds a row to a keyValue block. |
blocks.updateRow |
Changes a row of a keyValue block. |
blocks.deleteRow |
Removes a row from a keyValue block. |
blocks.listProperties |
Returns the properties of an infobox. |
blocks.createProperty |
Adds a property to an infobox. |
blocks.getProperty |
Returns one property of an infobox. |
blocks.updateProperty |
Changes a property of an infobox. |
blocks.deleteProperty |
Removes a property from an infobox. |
blocks.listPropertyRows |
Returns the rows of a keyValue property in an infobox. |
blocks.createPropertyRow |
Adds a row to a keyValue property in an infobox. |
blocks.updatePropertyRow |
Changes a row of a keyValue property in an infobox. |
blocks.deletePropertyRow |
Removes a row from a keyValue property in an infobox. |
boards
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
try {
const board = { resourceId: ooo.id, documentId: ooo.documents[0]!.id }
const empty = await lk.boards.getContent(board)
console.log(empty.pages.map((page) => page.name).join(', '), empty.shapes.length, 'shapes')
const created = await lk.boards.createShapes(board, {
ifMatch: empty.version,
shapes: [
{ id: 'shape:candy-kingdom', type: 'geo', x: 0, y: 0, props: { w: 220, h: 120, text: 'Candy Kingdom', color: 'violet', fill: 'solid' } },
{ id: 'shape:ice-kingdom', type: 'geo', x: 480, y: 0, props: { w: 220, h: 120, text: 'Ice Kingdom', color: 'light-blue', fill: 'solid' } },
{ id: 'shape:rivalry', type: 'arrow', props: { text: 'kidnaps princesses from' } },
{ type: 'note', x: 0, y: 220, props: { text: 'Gunter was last seen here' } },
],
bindings: [
{ type: 'arrow', fromId: 'shape:rivalry', toId: 'shape:ice-kingdom', props: { terminal: 'start' } },
{ type: 'arrow', fromId: 'shape:rivalry', toId: 'shape:candy-kingdom', props: { terminal: 'end' } },
],
})
console.log(created.shapes.length, 'shapes and', created.bindings.length, 'bindings')
const updated = await lk.boards.updateShapes(board, { ifMatch: created.version, shapes: [{ id: 'shape:ice-kingdom', y: 60, props: { color: 'blue' } }] })
const deleted = await lk.boards.deleteShapes(board, { ids: ['shape:rivalry'], ifMatch: updated.version })
console.log('deleted', deleted.shapeIds.join(', '), 'and', deleted.bindingIds.length, 'bindings')
const shapes = await lk.boards.listShapes(board)
console.log(shapes.data.map((shape) => `${shape.type} at ${shape.x},${shape.y}`).join('\n'))
const replaced = await lk.boards.replaceContent(board, {
ifMatch: deleted.version,
shapes: [{ type: 'text', x: 0, y: 0, props: { text: 'The Land of Ooo' } }],
})
console.log(replaced.shapes.length, 'shape after the replace')
} finally {
await lk.resources.delete({ resourceId: ooo.id })
}
A board is a document of type board. It holds tldraw 3.9 records: pages, shapes, bindings and assets. boards.getContent returns them as tldraw stores them, so tldraw's TLShape, TLBinding and TLAsset types describe them. The response also includes schema, the tldraw schema that writes are validated against, and the board version.
| Operation | Does |
|---|---|
boards.getContent |
Returns every page, shape, binding and asset record of the board, with schema and version. |
boards.listShapes |
Returns every shape ordered by index. Pass pageId to get only the shapes on one page. Not paginated. |
boards.createShapes |
Creates shapes and the optional bindings and assets, up to 500 of each. |
boards.updateShapes |
Changes up to 500 shapes, each identified by its id. |
boards.deleteShapes |
Deletes the shapes listed in ids, up to 500. |
boards.replaceContent |
Deletes every shape, binding and asset and stores the ones sent. Pages are kept. |
Every write takes ifMatch, returns the new version, and is atomic: when any record fails, nothing is stored.
A new shape needs only type. x, y, rotation, index, parentId and every prop it omits take the app's defaults. An arrow attaches to shapes through two bindings, one per terminal, as the example shows.
Image, video and bookmark assets reference their files by URL. See Image URLs.
timelines and calendars
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const calendars = await lk.calendars.list()
console.log(calendars.data.map((calendar) => calendar.name ?? calendar.id).join(', '))
const brothers = await lk.resources.create({ name: 'Finn and Jake' })
try {
const timeline = { resourceId: brothers.id, documentId: brothers.documents[0]!.id }
const finn = await lk.timelines.createLane(timeline, { name: 'Finn', rank: 'a0', size: 'lg' })
const jake = await lk.timelines.createLane(timeline, { name: 'Jake', rank: 'a1', size: 'sm', style: { color: '#f4a300' } })
await lk.timelines.updateLane({ ...timeline, laneId: jake.id }, { name: 'Jake the Dog' })
console.log((await lk.timelines.getLane({ ...timeline, laneId: jake.id })).name)
const year = 60 * 24 * 365
const mud = await lk.timelines.createEvent(timeline, { laneId: jake.id, type: 'event', name: 'Rolls in the magic mud puddle', start: 1000 * year })
const found = await lk.timelines.createEvent(timeline, { laneId: jake.id, type: 'event', name: 'Jake’s family finds baby Finn', start: 1016 * year })
const heroes = await lk.timelines.createEvent(timeline, {
laneId: finn.id,
type: 'event',
name: 'Adventuring out of the Tree Fort',
start: 1028 * year,
end: 1033 * year,
})
console.log(mud.formattedStart, heroes.formattedStart, heroes.formattedEnd)
console.log(heroes.startDate.year, heroes.startDate.era.abbr, heroes.startDate.iso, heroes.endDate?.iso)
await lk.timelines.updateEvent({ ...timeline, eventId: found.id }, { laneId: finn.id, name: 'Found in the woods by the Dogs' })
const moved = await lk.timelines.getEvent({ ...timeline, eventId: found.id })
console.log(moved.name, moved.laneId === finn.id)
for await (const event of await lk.timelines.listEvents(timeline, { limit: 50 })) console.log(event.start, event.name)
for (const lane of (await lk.timelines.listLanes(timeline)).data) console.log(lane.rank, lane.name, lane.size)
await lk.timelines.deleteEvent({ ...timeline, eventId: mud.id })
await lk.timelines.deleteLane({ ...timeline, laneId: jake.id })
} finally {
await lk.resources.delete({ resourceId: brothers.id })
}
A timeline is a document of type time. It holds lanes, and each event sits on one lane. Create a lane before any event: the first lane converts a blank document to a timeline.
| Operation | Does |
|---|---|
calendars.list |
The project's custom calendars, then the built-in ones, each with its version. Not paginated. |
calendars.create |
Creates a custom calendar from a calendar spec and returns it with its id and version. |
calendars.update |
Replaces a custom calendar with a new spec. Takes ifMatch. |
calendars.delete |
Deletes a custom calendar. Takes ifMatch. |
timelines.listLanes |
Every lane, including lanes of chained timelines. Order by rank. |
timelines.createLane |
Creates a lane. name, rank and size (sm or lg) are required. Converts a blank document to a timeline. |
timelines.getLane |
One lane of this document. |
timelines.updateLane |
Changes any of name, rank, size and style. |
timelines.deleteLane |
Deletes the lane and every event on it. |
timelines.listEvents |
A page of events ordered by start, including events of chained timelines. |
timelines.createEvent |
Creates an event. laneId, type (event or note) and a start are required. |
timelines.getEvent |
One event. |
timelines.updateEvent |
Changes the fields sent. laneId moves the event to another lane. |
timelines.deleteEvent |
Deletes the event. |
An event's start and end are minutes on the calendar's timeline. The example approximates a year as 60 * 24 * 365 minutes, so its dates drift by a day every few years.
Instead of start, you can send formattedStart, a date written in the document's calendar, such as 1000, June 2012 or March 3rd, 2012. The server parses it into start and detail, the precision of the date (1 for a year, 2 for a month, 3 for a day, 4 for a time). formattedEnd does the same for end. The worked example writes its dates this way.
Reads include formattedStart and formattedEnd rendered in the document's calendar. They also include startDate and, when the event has an end, endDate: the date as { year, month, monthName, day, hour, minute, era }, where era is { id, name, abbr }. On the Gregorian calendar, the date also has iso.
The document's calendar decides what a date means. Set it with calendarId on documents.create or documents.update; documents.get returns it. Changing it moves no event: every event keeps its time and renders in the new calendar.
rank on lanes is a fractional index string, such as a0, a1 and a2. Lanes sort by comparing ranks as strings. To place a lane between two others, give it a rank that sorts between theirs, such as a0V between a0 and a1.
An event's uri links it to a resource or document, with the same rules as a feature's uri (see features). imageUrl on an event and style.imageUrl on a lane take the url of an image asset of the project (see Image URLs).
Chaining timelines
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
const candy = await lk.resources.create({ name: 'The Candy Kingdom' })
try {
const history = { resourceId: ooo.id, documentId: ooo.documents[0]!.id }
const kingdom = { resourceId: candy.id, documentId: candy.documents[0]!.id }
await lk.timelines.createLane(history, { name: 'The Land of Ooo', rank: 'a0', size: 'lg' })
await lk.timelines.createLane(kingdom, { name: 'The Candy Kingdom', rank: 'a1', size: 'sm' })
const chained = await lk.documents.update(history, { sources: [kingdom] })
console.log(chained.sources)
for (const lane of (await lk.timelines.listLanes(history)).data) console.log(lane.rank, lane.name)
await lk.documents.update(history, { sources: [] })
console.log((await lk.timelines.listLanes(history)).data.length)
} finally {
await lk.resources.delete({ resourceId: ooo.id })
await lk.resources.delete({ resourceId: candy.id })
}
A timeline can chain other timelines as its sources. timelines.listLanes and timelines.listEvents on the chaining timeline include the lanes and events of each source, and of the sources' own sources in turn. timelines.getLane and timelines.getEvent read only the timeline's own lanes and events.
To chain timelines, pass sources to documents.update on a time document. It replaces the list, and [] removes every source. documents.get, documents.list, documents.create and documents.update return sources on a time document as an ordered list of { resourceId, documentId }.
Details:
- A source the caller cannot read is left out of the lanes and events, as well as out of
sources.
Calendars
import { LegendKeeper, ValidationError, VersionMismatchError, type components } from '@legendkeeper/sdk'
type CalendarSpec = components['schemas']['CalendarSpec']
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const month = (id: string, name: string, length: number) => ({ id, name, isIntercalary: false, length, interval: 1, offset: 0 })
const spec: CalendarSpec = {
name: 'The Calendar of Ooo',
description: 'Four kingdom months of thirty days and the five-day Mathematical festival, counted from the Great Mushroom War.',
hasZeroYear: false,
maxMinutes: 1_800_000_000,
months: [
month('candy', 'Candy', 30),
month('ice', 'Ice', 30),
month('flame', 'Flame', 30),
month('lumpy-space', 'Lumpy Space', 30),
{ ...month('mathematical', 'Mathematical', 5), isIntercalary: true },
],
leapDays: [
{ id: 'gunter-day', name: 'Gunter Day', intercalary: false, month: 3, addsWeekDay: false, day: 1, weekDay: '', interval: '4', offset: 0 },
],
weekdays: [
{ id: 'finn', name: 'Finnsday' },
{ id: 'jake', name: 'Jakesday' },
{ id: 'bubblegum', name: 'Bubblegumsday' },
{ id: 'marceline', name: 'Marcelinesday' },
{ id: 'bmo', name: 'BMOsday' },
],
epochWeekday: 0,
weekResetsEachMonth: false,
hoursInDay: 24,
minutesInHour: 60,
negativeEra: { id: 'before-war', name: 'Before the Mushroom War', abbr: 'BMW', hideAbbr: false, startsAt: -180_000_000, resetMode: 'none' },
positiveEras: [{ id: 'after-war', name: 'After the Mushroom War', abbr: 'AMW', hideAbbr: false, startsAt: 0, resetMode: 'none' }],
moons: [{ id: 'the-moon', name: 'The Moon', phase: 40_320, shift: 0, color: '#E8E4D8' }],
format: { id: 'ooo', year: 'YYYY E', month: 'MMMM YYYY E', day: 'MMMM D^, YYYY E', time: 'MMMM D^, YYYY E, HH:mm' },
halfClock: false,
}
const calendar = await lk.calendars.create(spec)
let version = calendar.version
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
try {
const timeline = { resourceId: ooo.id, documentId: ooo.documents[0]!.id }
const lane = await lk.timelines.createLane(timeline, { name: 'Kingdoms', rank: 'a0', size: 'lg' })
await lk.documents.update(timeline, { calendarId: calendar.id })
console.log((await lk.documents.get(timeline)).calendarId === calendar.id)
await lk.timelines.createEvent(timeline, { laneId: lane.id, type: 'event', name: 'Princess Bubblegum makes the first Candy Person', formattedStart: '200 AMW' })
await lk.timelines.createEvent(timeline, { laneId: lane.id, type: 'event', name: 'Finn is found in the woods', formattedStart: 'Flame 14th, 1016 AMW' })
for await (const event of await lk.timelines.listEvents(timeline)) {
const { day, monthName, year, era } = event.startDate
console.log(event.formattedStart, event.name, '|', day, monthName, year, era.abbr)
}
const renamed = await lk.calendars.update({ calendarId: calendar.id }, { ...spec, name: 'The Ooo Reckoning', ifMatch: version })
version = renamed.version
try {
await lk.calendars.update({ calendarId: calendar.id }, { ...spec, ifMatch: calendar.version })
} catch (error) {
if (!(error instanceof VersionMismatchError)) throw error
console.log(`stale version refused: ${error.code}`)
}
try {
await lk.calendars.update({ calendarId: 'gregorian' }, spec)
} catch (error) {
if (!(error instanceof ValidationError)) throw error
console.log(`built-in calendars cannot change: ${error.code}`)
}
await lk.documents.update(timeline, { calendarId: 'harptos' })
for await (const event of await lk.timelines.listEvents(timeline)) console.log(event.formattedStart, event.name)
} finally {
await lk.resources.delete({ resourceId: ooo.id })
await lk.calendars.delete({ calendarId: calendar.id }, { ifMatch: version })
}
The example creates the Calendar of Ooo, sets it as the timeline's calendarId, and writes two events whose formattedStart follows that calendar's format. Moving the timeline to harptos renders the same two events as Harptos dates.
A calendar spec defines the months, leap days, weekdays, clock, eras, moons and date format of a year. The API reference documents each field. Its CalendarSpec example is the Calendar of Ooo used here.
calendars.update replaces the whole spec. To avoid overwriting another change, pass the calendar version from calendars.list, calendars.create or an earlier update as ifMatch. A calendar changed since then fails with 412, thrown as VersionMismatchError.
The built-in calendars are gregorian, harptos, eberron, exandria, greyhawk and elder-scrolls. You can't change or delete them. To change one, create a copy with calendars.create and point timelines at the copy.
Details:
- A spec must describe a calendar that renders a date, or
calendars.createfails with 400VALIDATION_ERROR. calendars.updateandcalendars.deletewith an unknowncalendarIdreturn 404NOT_FOUND.
features
import { LegendKeeper, ValidationError } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
const fort = await lk.resources.create({ name: 'The Tree Fort' })
try {
const map = { resourceId: ooo.id, documentId: ooo.documents[0]!.id }
const homes = await lk.features.create(map, { type: 'folder', name: 'Homes' })
const pin = await lk.features.create(map, {
type: 'pin',
name: 'The Tree Fort',
pos: [12.5, -40.25],
parentId: homes.id,
uri: `lk://resources/${fort.id}`,
iconShape: 'none',
})
const ice = await lk.features.create(map, {
type: 'region',
name: 'Ice Kingdom',
polygon: [[40, -60], [40, -20], [30, -20], [30, -60]],
color: '#a8d8ff',
fillOpacity: 0.3,
})
await lk.features.update({ ...map, featureId: pin.id }, { pos: [12.75, -40.5], iconGlyph: 'tree' })
const moved = await lk.features.get({ ...map, featureId: pin.id })
console.log(moved.name, moved.pos, moved.parentId === homes.id, moved.uri, moved.iconShape)
const [fortPage] = fort.documents
if (!fortPage) throw new Error('a new resource has one document')
await lk.features.update({ ...map, featureId: ice.id }, { uri: `lk://documents/${fortPage.id}` })
try {
await lk.features.update({ ...map, featureId: ice.id }, { uri: 'https://example.com/ice-kingdom' })
} catch (error) {
if (!(error instanceof ValidationError)) throw error
for (const issue of error.errors ?? []) console.log(`${error.code} at ${issue.path}`)
}
console.log((await lk.features.get({ ...map, featureId: ice.id })).uri)
await lk.features.update({ ...map, featureId: ice.id }, { uri: '' })
for (const feature of (await lk.features.list(map)).data) console.log(feature.type, feature.name)
console.log((await lk.features.list(map, { type: 'pin' })).data.length, 'pins')
await lk.features.delete({ ...map, featureId: ice.id })
} finally {
await lk.resources.delete({ resourceId: ooo.id })
await lk.resources.delete({ resourceId: fort.id })
}
A map is a document of type map. Features are its pins, labels, regions, paths and folders.
A new map has no image. It still stores and returns features, but the app shows "This map is empty" until the map has an image. To set one, see Map images.
| Operation | Does |
|---|---|
features.list |
Every feature, or only those of one type. Not paginated. |
features.create |
Creates a feature. Every field is optional. type selects the kind and pos places it. |
features.get |
One feature. |
features.update |
Changes the fields sent. |
features.delete |
Deletes the feature. Deleting a folder leaves the features in it in place; their parentId still names the deleted folder. |
pos is [lat, lng] in the map's coordinate frame. parentId nests a feature in a folder, and rank orders it among its siblings.
Each type has its own shape fields:
- Regions take
polygon, a list of[lat, lng]points, orpolygons, with fill and border styles. - Paths take
polylineand stroke styles. - Pins take
pinImage,{ url, width, height }(see Image URLs), andiconGlyph,iconColor,iconShapeandiconSizewith the values of resources.
Every color is a hex string #RRGGBB. Each color field applies to some feature types only:
| Field | Types | Colors | Absent |
|---|---|---|---|
iconColor |
pin |
The pin shape. The glyph is drawn black or white for contrast. No effect with iconShape none or a pinImage. A pin synced to a resource through uri draws that resource's iconColor instead. |
#A7A7A7 |
color |
region, path |
A region's fill and border; a path's line. | #3B82F6 |
textColor |
label, region, path |
The name text: a label's text, or the name drawn on a region or path. | #FFFFFF |
textEffect |
label, region, path |
How textEffectColor is drawn: outline around the letters, or background, a box behind the text at 50% opacity. |
outline |
textEffectColor |
label, region, path |
The outline or the background box. | #000000 |
Details:
- Reads return colors as stored, so a feature drawn in the app before this API existed may have a color in another CSS form.
uri links a feature, or a timeline event, to a resource or a document in the same project. It takes one of three forms:
| Form | Links to |
|---|---|
lk://resources/<resourceId> |
A resource. |
lk://documents/<documentId> |
A document. |
lk://resources/<resourceId>/docs/<documentId> |
A document of that resource, in the legacy form. |
An empty string clears the link.
Map images
import { deflateSync } from 'node:zlib'
import { LegendKeeper, pixelToPos, posToPixel } from '@legendkeeper/sdk'
type Rgb = [number, number, number]
const kingdoms: { name: string; pixel: [number, number]; color: Rgb }[] = [
{ name: 'Candy Kingdom', pixel: [520, 420], color: [255, 160, 200] },
{ name: 'Ice Kingdom', pixel: [220, 170], color: [220, 240, 255] },
{ name: 'Fire Kingdom', pixel: [830, 610], color: [255, 110, 40] },
]
const grass: Rgb = [120, 200, 90]
const kingdomAt = (x: number, y: number) => kingdoms.find(({ pixel }) => Math.hypot(x - pixel[0], y - pixel[1]) < 110)
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo' })
const art = await lk.assets.uploadImage({
data: png(1024, 768, (x, y) => kingdomAt(x, y)?.color ?? grass),
contentType: 'image/png',
name: 'Map of Ooo',
})
try {
const map = { resourceId: ooo.id, documentId: ooo.documents[0]!.id }
const updated = await lk.documents.update(map, { mapImage: { assetId: art.id } })
const image = updated.mapImage
if (!image) throw new Error('the map has an image')
console.log(image.width, image.height, JSON.stringify(image.bounds), image.assetId === art.id)
for (const kingdom of kingdoms) {
const [x, y] = kingdom.pixel
await lk.features.create(map, { type: 'pin', name: kingdom.name, pos: pixelToPos(image, x, y) })
}
for (const pin of (await lk.features.list(map, { type: 'pin' })).data) {
if (!pin.pos) continue
console.log(pin.name, JSON.stringify(pin.pos), JSON.stringify(posToPixel(image, pin.pos)))
}
} finally {
await lk.resources.delete({ resourceId: ooo.id })
await lk.assets.delete({ assetId: art.id })
}
function crc32(bytes: Uint8Array): number {
let crc = ~0
for (const byte of bytes) {
crc ^= byte
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
}
return ~crc >>> 0
}
function pngChunk(type: string, data: Uint8Array): Buffer {
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const chunk = Buffer.alloc(body.length + 8)
chunk.writeUInt32BE(data.length, 0)
body.copy(chunk, 4)
chunk.writeUInt32BE(crc32(body), body.length + 4)
return chunk
}
function png(width: number, height: number, colorAt: (x: number, y: number) => Rgb): Buffer {
const header = Buffer.alloc(13)
header.writeUInt32BE(width, 0)
header.writeUInt32BE(height, 4)
header.set([8, 2, 0, 0, 0], 8)
const stride = width * 3 + 1
const rows = Buffer.alloc(stride * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) rows.set(colorAt(x, y), y * stride + 1 + x * 3)
}
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
return Buffer.concat([signature, pngChunk('IHDR', header), pngChunk('IDAT', deflateSync(rows)), pngChunk('IEND', new Uint8Array())])
}
To set or replace a map's image, upload it with assets.uploadImage (see Uploading images). Then pass mapImage: { assetId } to documents.update. The returned document's mapImage has assetId, url, width, height and bounds.
bounds is [[south, west], [north, east]], the [lat, lng] corners the image covers. To place features by pixel, use pixelToPos(mapImage, x, y), which converts a pixel counted from the image's top-left corner to a pos. posToPixel(mapImage, pos) converts back. Both are exported from @legendkeeper/sdk.
assets
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const treasure = await lk.assets.createFolder({ name: 'Tree Fort treasure' })
try {
const scans = await lk.assets.createFolder({ name: 'Enchiridion', folderId: treasure.id })
await lk.assets.update({ assetId: scans.id }, { name: 'Enchiridion scans', description: 'Pages from the book of heroes, for the players' })
const renamed = await lk.assets.get({ assetId: scans.id })
console.log(renamed.name, renamed.type, renamed.folderId === treasure.id, renamed.description)
for await (const asset of await lk.assets.list({ folderId: treasure.id })) console.log('in the treasure folder:', asset.name)
await lk.assets.update({ assetId: scans.id }, { folderId: null })
const root = await lk.assets.list({ limit: 100 })
console.log(root.data.some((asset) => asset.id === scans.id))
await lk.assets.delete({ assetId: scans.id })
} finally {
await lk.assets.delete({ assetId: treasure.id })
}
Assets are the project's library of images, maps and folders. Use the API to create folders, upload images and manage existing assets. Any member can upload an image or create a folder. Every other write needs the ADMIN role, including updating or deleting an asset that you uploaded.
| Operation | Does |
|---|---|
assets.list |
Lists one folder's direct contents, or the root when folderId is absent. Items have the same fields as assets.get. Paginated. |
assets.createFolder |
Creates a folder. name is required. folderId nests it. Any member. |
assets.uploadImage |
Uploads one image and returns the created asset. It calls assets.createImageUpload, sends the bytes to storage, and calls assets.createImage. |
assets.createImageUpload |
Step one of an upload. Returns a signed uploadUrl, the method and headers to send with it, an uploadKey, and expiresAt. |
assets.createImage |
Step two. Registers the file stored under uploadKey as an image asset. |
assets.get |
Returns one asset: id, name, type, folderId, isHidden, description, url, and for an image, width and height. |
assets.update |
Changes any of name, description, folderId, and isHidden. Set folderId to null to move the asset to the root. ADMIN role. |
assets.delete |
Deletes the asset. Deleting a folder deletes everything inside it. ADMIN role. |
Uploading images
import { deflateSync } from 'node:zlib'
import { LegendKeeper, ValidationError } from '@legendkeeper/sdk'
import { image } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const album = await lk.assets.createFolder({ name: 'BMO’s photo album' })
const bmo = await lk.resources.create({ name: 'BMO' })
try {
const face = await lk.assets.uploadImage({ data: solidPng(32, [79, 187, 162]), contentType: 'image/png', name: 'BMO’s face', folderId: album.id })
console.log(face.type, face.width, face.height, face.url)
if (face.url === undefined) throw new Error('an uploaded image has a url')
const gum = solidPng(16, [255, 105, 180])
const upload = await lk.assets.createImageUpload({ contentType: 'image/png', contentLength: gum.byteLength })
const stored = await fetch(upload.uploadUrl, { method: upload.method, headers: upload.headers, body: gum })
if (!stored.ok) throw new Error(`storage answered ${stored.status}`)
const asset = await lk.assets.createImage({ uploadKey: upload.uploadKey, name: 'A piece of gum', folderId: album.id })
console.log(asset.name, asset.width, asset.height)
const page = bmo.documents[0]!
const doc = { resourceId: bmo.id, documentId: page.id }
await lk.documents.replaceBlocks(doc, { blocks: [image(face.url, 'BMO’s face')] })
await lk.resources.update({ resourceId: bmo.id }, { banner: { enabled: true, url: face.url, yPosition: 50 } })
try {
await lk.documents.replaceBlocks(doc, { blocks: [image('data:image/png;base64,iVBORw0KGgo=', 'Pasted from the clipboard')] })
} catch (error) {
if (!(error instanceof ValidationError)) throw error
for (const issue of error.errors ?? []) console.log(`${error.code} at ${issue.path}`)
}
} finally {
await lk.resources.delete({ resourceId: bmo.id })
await lk.assets.delete({ assetId: album.id })
}
function crc32(bytes: Uint8Array): number {
let crc = ~0
for (const byte of bytes) {
crc ^= byte
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
}
return ~crc >>> 0
}
function pngChunk(type: string, data: Uint8Array): Buffer {
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const chunk = Buffer.alloc(body.length + 8)
chunk.writeUInt32BE(data.length, 0)
body.copy(chunk, 4)
chunk.writeUInt32BE(crc32(body), body.length + 4)
return chunk
}
function solidPng(size: number, rgb: [number, number, number]): Buffer {
const header = Buffer.alloc(13)
header.writeUInt32BE(size, 0)
header.writeUInt32BE(size, 4)
header.set([8, 2, 0, 0, 0], 8)
const row = [0, ...Array.from({ length: size }, () => rgb).flat()]
const pixels = deflateSync(Buffer.from(Array.from({ length: size }, () => row).flat()))
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
return Buffer.concat([signature, pngChunk('IHDR', header), pngChunk('IDAT', pixels), pngChunk('IEND', new Uint8Array())])
}
assets.uploadImage takes these options:
data: the file's bytes, as aUint8Array, anArrayBufferor aBlob. A NodeBufferand a browserFileboth work.contentType: one ofimage/jpeg,image/jpg,image/png,image/gifandimage/webp. You can omit it whendatais aBlobwhosetypeis set.name: the asset name.descriptionandfolderId: optional.
The example builds a small PNG in code, so it needs no file.
To send the bytes yourself, call the two API steps, as the second upload in the example does. Call assets.createImageUpload, send the file with PUT to uploadUrl, then pass the uploadKey to assets.createImage. The server records the file's real size, type and pixel dimensions.
An image asset is always hosted on LegendKeeper storage. To show an image in content, such as an image block or a resource banner, use the asset's url. Image fields accept no other URL, and no operation registers an image from another URL.
Details:
assets.uploadImageholds the whole file in memory and sends its byte length and SHA-256 withassets.createImageUpload. Storage refuses bytes that do not match.assets.uploadImagesends the bytes touploadUrlwith only the returnedheaders, never the API key. When storage refuses them, it throws aLegendKeeperErrorthat includes the storage status.expiresAtis ten minutes after theassets.createImageUploadcall.- A stored file that is not an image of an accepted type fails with 400
VALIDATION_ERRORatuploadKey.
Uploads count against the project owner's plan and the uploading user:
| Limit | Answer |
|---|---|
| The project owner's storage quota: 100 GB, across every project they own | 413 STORAGE_QUOTA_EXCEEDED, from either step. |
| Upload bytes per uploading user in a rolling 24 hours: 10 GB | 429 UPLOAD_RATE_EXCEEDED with Retry-After |
| Uploads per uploading user: 50 a minute and 500 an hour | 429 UPLOAD_RATE_EXCEEDED |
assets.createImageUpload calls per IP address: 20 a minute and 200 an hour |
429 RATE_LIMITED |
The client retries a 429 like any other. See Retries.
search
import { LegendKeeper } from '@legendkeeper/sdk'
import { p } from '@legendkeeper/sdk/blocks'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const iceKing = await lk.resources.create({ name: 'The Ice King', aliases: ['Simon Petrikov'] })
try {
const page = iceKing.documents[0]!
await lk.documents.replaceBlocks(
{ resourceId: iceKing.id, documentId: page.id },
{ blocks: [p('Kidnaps princesses. Writes fan fiction about Fionna and Cake.')] },
)
for (const query of ['Ice King', 'Simon Petrikov', 'Fionna']) {
const hits = await lk.search.search({ q: query, limit: 10 })
for (const hit of hits.data) {
const where = hit.document ? `in ${hit.document.name}: ${hit.document.highlights?.[0]?.segment}` : 'by name'
const trail = (hit.resource.path ?? []).map((ancestor) => ancestor.name).join(' / ')
console.log(`${query} -> ${hit.resource.name} ${where} ${trail}`)
}
}
} finally {
await lk.resources.delete({ resourceId: iceKing.id })
}
| Operation | Does |
|---|---|
search.search |
Resources whose name, aliases or document content match q, best match first, up to limit (1 to 100, default 32). |
Each result is { resource, document? }. resource has the resource's id, name, icon fields, parentId, tags and path, its ancestors from the root down. document is present when the match is in a document's content, and has the document's id, name and highlights, the matching passage.
search.search returns one page of results and does not paginate.
tags
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const finn = await lk.resources.create({ name: 'Finn', tags: ['NPC', 'Tree Fort', 'Hero'] })
const jake = await lk.resources.create({ name: 'Jake', tags: ['NPC', 'Tree Fort', 'Dog'] })
try {
for (const { tag, count } of (await lk.tags.list()).data) console.log(`${tag}: ${count}`)
} finally {
await lk.resources.delete({ resourceId: finn.id })
await lk.resources.delete({ resourceId: jake.id })
}
| Operation | Does |
|---|---|
tags.list |
Lists every distinct tag on the project's resources with count, the number of resources that have it, most used first. Resources in the trash are not counted. ADMIN role. |
members and invites
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
for (const member of (await lk.members.list()).data) console.log(member.role, member.name ?? member.email ?? member.id)
const email = `new-player-${Date.now()}@example.com`
const result = await lk.members.invite({ email })
console.log(result.success, result.alreadyMember ?? false, result.alreadyInvited ?? false)
const pending = await lk.invites.list()
const invite = pending.data.find((item) => item.email === email)
if (!invite) throw new Error(`no pending invite for ${email}`)
console.log(invite.role, invite.expiresAt)
await lk.invites.revoke({ inviteId: invite.id })
console.log((await lk.invites.list()).data.some((item) => item.id === invite.id))
| Operation | Does |
|---|---|
members.list |
Lists every member with their role: OWNER, ADMIN or MEMBER. A member's id is their user id. email is null unless you have the ADMIN role. |
members.invite |
Invites an email as a MEMBER. Creates a pending invite that expires after 7 days and emails an accept link. ADMIN role. |
members.remove |
Removes a member. You can remove a member whose role ranks below yours, and any member other than the owner can remove themselves. |
invites.list |
Lists pending invites, with email, role and expiresAt. ADMIN role. |
invites.revoke |
Revokes a pending invite so its link no longer works. ADMIN role. |
Every invite sends an email, so members.invite is rate limited. When the project has too many pending invites, it fails with 409 TOO_MANY_PENDING_INVITES until invites are revoked, accepted or expire.
A key acts with its creator's role, capped at the key's own role. An ADMIN key can remove only members with the MEMBER role. Change roles in the app.
Details:
- The
members.invitelimits also apply to invites sent from the app. members.invitethrows 409TOO_MANY_PENDING_INVITESasConflictError. Waiting for the rate limits to reset does not clear it.
projects
import { LegendKeeper } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const project = await lk.project()
console.log(project.id, project.name, project.isPublic)
const listed = await lk.projects.list()
console.log(listed.data.length, listed.data[0]?.id === project.id)
const fetched = await lk.projects.get({ projectId: project.id })
console.log(fetched.createdAt)
| Operation | Does |
|---|---|
projects.list |
Returns a page of your projects. With an API key, the page holds only the key's project. Paginated. |
projects.get |
Returns the project: id, name, isPublic and createdAt. |
lk.project() returns the same object as projects.get for the key's project.
The CLI
The package includes a CLI that calls any operation by namespace and method and prints the JSON response. Run it with npx @legendkeeper/sdk.
npx @legendkeeper/sdk --help
npx @legendkeeper/sdk resources --help
npx @legendkeeper/sdk projects list --pretty
npx @legendkeeper/sdk resources list --query.limit 5 --paginate
created=$(npx @legendkeeper/sdk resources create --body '{"name":"The Tree Fort","tags":["Home"]}')
id=$(node -p 'JSON.parse(process.argv[1]).id' "$created")
npx @legendkeeper/sdk resources update --resourceId "$id" --body '{"name":"Finn and Jake’s Tree Fort"}'
npx @legendkeeper/sdk resources get --resourceId "$id" --pretty
npx @legendkeeper/sdk resources delete --resourceId "$id"
npx @legendkeeper/sdk --help lists the namespaces. npx @legendkeeper/sdk <namespace> --help lists each operation with its HTTP method, path and path parameters.
| Flag | Effect |
|---|---|
--<pathParam> value |
A path parameter, such as --resourceId. --projectId is optional because the key determines its project. |
--query.<name> value |
A query parameter, such as --query.limit 5. |
--body '<json>' |
The request body as a JSON object. --body @file.json reads it from a file. |
--if-match <version> |
Sends the version as ifMatch. |
--paginate |
Follows every page of a list and prints one array of all items. |
--pretty |
Indents the JSON. |
--api-key <key> |
The key. Defaults to LEGENDKEEPER_API_KEY. |
--base-url <url> |
The server. Defaults to LEGENDKEEPER_BASE_URL, then production. |
Use --name=value when a value starts with --. Without --paginate, a list prints its first page as { data, hasMore, nextCursor }. An operation that returns nothing, such as a delete, prints null.
The command exits with one of these codes:
- 0: success.
- 1: an API or connection error. An API error is printed to stderr as JSON with
status,message,code,requestIdanderrors. A connection error printsmessageandcode: "CONNECTION_ERROR". - 2: a usage error, such as an unknown flag or a missing path parameter.
The escape hatch
import { LegendKeeper, NotFoundError } from '@legendkeeper/sdk'
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const project = await lk.project()
type CalendarList = { data: { id: string; name?: string }[] }
const calendars = await lk.request<CalendarList>({
method: 'GET',
path: `/api/v3/projects/${project.id}/calendars`,
headers: { 'x-request-id': 'guide-escape-hatch' },
})
console.log(calendars.data.length, 'calendars')
const created = await lk.request<{ id: string; name: string }>({
method: 'POST',
path: `/api/v3/projects/${project.id}/resources`,
body: { name: 'The Tree Fort' },
})
await lk.request({ method: 'DELETE', path: `/api/v3/projects/${project.id}/resources/${created.id}` })
try {
await lk.request({ method: 'GET', path: `/api/v3/projects/${project.id}/resources/${created.id}` })
} catch (error) {
if (!(error instanceof NotFoundError)) throw error
console.log(`deleted: ${error.code}`)
}
Use lk.request({ method, path, query, body, headers, signal }) for an endpoint that this version of the SDK does not wrap yet. It sends the request with the client's key, base URL, timeout, retries and error mapping. It does not generate an Idempotency-Key: for a POST, pass one in headers so that a retry cannot apply the write twice. It resolves with the parsed JSON body, typed as the type argument (unknown by default).
The client sends path as written and does not fill in projectId or other parameters. Build the full path yourself, as the example does with lk.project().
Details:
queryvalues are converted withString().undefinedandnullvalues are left out.bodyis sent as JSON, andheadersare added to the request.- An error status throws the same classes as a wrapped call.
TypeScript types
import { LegendKeeper, type Operation, type Options, type Result, type components } from '@legendkeeper/sdk'
import { BlockKind, h, p, plainText, type Block, type HeadingBlock } from '@legendkeeper/sdk/blocks'
type Resource = components['schemas']['Resource']
type CreateResource = Extract<Operation, { id: 'createResource' }>
type CreateResourceOptions = Options<CreateResource>
type CreatedResource = Result<CreateResource>
function headings(blocks: readonly Block[]): string[] {
return blocks.filter((block): block is HeadingBlock => block.kind === BlockKind.heading).map((block) => plainText(block))
}
function summary(resource: Resource): string {
return `${resource.name}: ${resource.documents.length} documents, tags ${resource.tags.join(', ')}`
}
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const options: CreateResourceOptions = { name: 'Ice Kingdom', tags: ['Kingdom'] }
const created: CreatedResource = await lk.resources.create(options)
try {
const page = created.documents[0]!
const written = await lk.documents.replaceBlocks(
{ resourceId: created.id, documentId: page.id },
{ blocks: [h(1, 'Ice Kingdom'), p('Cold. Full of penguins.'), h(2, 'Residents'), p('The Ice King, and Gunter, who is the dangerous one.')] },
)
console.log(headings(written.blocks))
console.log(summary(await lk.resources.get({ resourceId: created.id })))
} finally {
await lk.resources.delete({ resourceId: created.id })
}
The package exports:
components, the generated schema types. Every schema in the OpenAPI spec is there by name. For example,components['schemas']['Resource']is a resource asresources.getreturns it.paths, the generated path types.Operation, the union of every operation in the client. Select one withExtract<Operation, { id: 'createResource' }>. The ids are theoperationIdvalues in the reference.Options<O>andResult<O>, the options and result type of one operation.Page<T>andListEnvelope<T>, the list types.ErrorCode,ErrorBodyandErrorIssue, and every error class.- From
@legendkeeper/sdk/blocks:Block, one type per kind (such asHeadingBlock),Run,DocumentBlocks,PropertyBlockand the enum types.
Block is a union discriminated by kind, so a kind check narrows it, as headings does in the example. Results of versioned operations add version: string. List operations resolve to Page<Item>.
Worked example
This example builds a small Land of Ooo and deletes it at the end, the uploaded image included. It creates:
- A world resource with three kingdoms under it. Each kingdom has a sidebar property and a page.
- An overview page that mentions every kingdom.
- A map with a generated image, a pin for each kingdom, and one for the Tree Fort. The pins are placed by pixel with
pixelToPos, as Map images describes. - A timeline from the Great Mushroom War to the Enchiridion, in the Gregorian calendar. Calendars shows a timeline in a calendar of Ooo's own.
The example draws the 1024 by 768 PNG map image in code, so it needs no file.
import { deflateSync } from 'node:zlib'
import { LegendKeeper, pixelToPos } from '@legendkeeper/sdk'
import { callout, h, keyValue, mention, p, plainText, property, ul } from '@legendkeeper/sdk/blocks'
type Rgb = [number, number, number]
type Kingdom = { name: string; ruler: string; summary: string; landmarks: [string, ...string[]]; pixel: [number, number]; color: Rgb }
const kingdoms: Kingdom[] = [
{ name: 'Candy Kingdom', ruler: 'Princess Bubblegum', summary: 'Candy people, a castle, and a princess who runs it like a lab.', landmarks: ['Candy Castle', 'Candy Village'], pixel: [520, 420], color: [255, 160, 200] },
{ name: 'Ice Kingdom', ruler: 'The Ice King', summary: 'A lonely king, a mountain of ice and far too many penguins.', landmarks: ['Ice Castle', 'The fan fiction shelf'], pixel: [220, 170], color: [220, 240, 255] },
{ name: 'Fire Kingdom', ruler: 'Flame Princess', summary: 'Hot enough that Finn needed a fire shield to visit.', landmarks: ['Fire Palace'], pixel: [830, 610], color: [255, 110, 40] },
]
const treeFort: [number, number] = [400, 520]
const grass: Rgb = [120, 200, 90]
const kingdomAt = (x: number, y: number) => kingdoms.find(({ pixel }) => Math.hypot(x - pixel[0], y - pixel[1]) < 110)
const lk = new LegendKeeper({ apiKey: process.env.LEGENDKEEPER_API_KEY! })
const ooo = await lk.resources.create({ name: 'The Land of Ooo', tags: ['World'] })
const art = await lk.assets.uploadImage({ data: png(1024, 768, (x, y) => kingdomAt(x, y)?.color ?? grass), contentType: 'image/png', name: 'Map of Ooo' })
const created: { id: string; name: string; pixel: [number, number] }[] = []
try {
for (const kingdom of kingdoms) {
const resource = await lk.resources.create({ name: kingdom.name, parentId: ooo.id, tags: ['Kingdom'] })
created.push({ id: resource.id, name: resource.name, pixel: kingdom.pixel })
await lk.properties.create({ resourceId: resource.id }, property(keyValue([['Ruler', kingdom.ruler]]), { title: 'Rule' }))
const page = resource.documents[0]!
const written = await lk.documents.replaceBlocks(
{ resourceId: resource.id, documentId: page.id },
{ blocks: [h(2, kingdom.name), p(kingdom.summary, ' Part of ', mention({ resourceId: ooo.id }), '.'), ul(...kingdom.landmarks)] },
)
console.log(plainText(written))
}
const overview = ooo.documents[0]!
await lk.documents.replaceBlocks(
{ resourceId: ooo.id, documentId: overview.id },
{
blocks: [
h(2, 'Kingdoms'),
...created.map((kingdom) => p(mention({ resourceId: kingdom.id }))),
callout('note', ['Everything here grew back after the Mushroom War.']),
],
},
)
const mapDocument = await lk.documents.create({ resourceId: ooo.id }, { name: 'Map of Ooo', type: 'map' })
const map = { resourceId: ooo.id, documentId: mapDocument.id }
const { mapImage } = await lk.documents.update(map, { mapImage: { assetId: art.id } })
if (!mapImage) throw new Error('the map has an image')
const folder = await lk.features.create(map, { type: 'folder', name: 'Kingdoms' })
for (const kingdom of created) {
const pos = pixelToPos(mapImage, ...kingdom.pixel)
await lk.features.create(map, { type: 'pin', name: kingdom.name, pos, parentId: folder.id, uri: `lk://resources/${kingdom.id}` })
}
await lk.features.create(map, { type: 'pin', name: 'The Tree Fort', pos: pixelToPos(mapImage, ...treeFort), iconGlyph: 'tree' })
const timelineDocument = await lk.documents.create({ resourceId: ooo.id }, { name: 'History of Ooo', type: 'time' })
const timeline = { resourceId: ooo.id, documentId: timelineDocument.id }
const before = await lk.timelines.createLane(timeline, { name: 'Before Finn', rank: 'a0', size: 'lg' })
const after = await lk.timelines.createLane(timeline, { name: 'Finn and Jake', rank: 'a1', size: 'lg' })
const events = [
{ laneId: before.id, name: 'The Great Mushroom War', formattedStart: '2000' },
{ laneId: before.id, name: 'Simon finds a lost little girl named Marceline', formattedStart: '2001' },
{ laneId: before.id, name: 'The crown wins; Simon is the Ice King', formattedStart: '2010' },
{ laneId: after.id, name: 'Finn and Jake find the Enchiridion', formattedStart: '3012' },
]
for (const event of events) await lk.timelines.createEvent(timeline, { ...event, type: 'event' })
for await (const event of await lk.timelines.listEvents(timeline)) console.log(event.formattedStart, event.name)
console.log((await lk.features.list(map, { type: 'pin' })).data.map((pin) => pin.name).join(', '))
} finally {
for (const kingdom of created) await lk.resources.delete({ resourceId: kingdom.id })
await lk.resources.delete({ resourceId: ooo.id })
await lk.assets.delete({ assetId: art.id })
}
function crc32(bytes: Uint8Array): number {
let crc = ~0
for (const byte of bytes) {
crc ^= byte
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
}
return ~crc >>> 0
}
function pngChunk(type: string, data: Uint8Array): Buffer {
const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
const chunk = Buffer.alloc(body.length + 8)
chunk.writeUInt32BE(data.length, 0)
body.copy(chunk, 4)
chunk.writeUInt32BE(crc32(body), body.length + 4)
return chunk
}
function png(width: number, height: number, colorAt: (x: number, y: number) => Rgb): Buffer {
const header = Buffer.alloc(13)
header.writeUInt32BE(width, 0)
header.writeUInt32BE(height, 4)
header.set([8, 2, 0, 0, 0], 8)
const stride = width * 3 + 1
const rows = Buffer.alloc(stride * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) rows.set(colorAt(x, y), y * stride + 1 + x * 3)
}
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
return Buffer.concat([signature, pngChunk('IHDR', header), pngChunk('IDAT', deflateSync(rows)), pngChunk('IEND', new Uint8Array())])
}
Reference
- API reference: https://www.legendkeeper.com/developers/api/
- SDK reference: https://www.legendkeeper.com/developers/sdk/
- OpenAPI spec: https://www.legendkeeper.com/api/v3/openapi.json