← Public packages
@kentcdodds/workflowy
Thin secret-backed helpers for the WorkFlowy REST API: targets, nodes, mirrors, and outline export.
src/index.ts
620 lines · 18.0 KB · TypeScriptconst API_ORIGIN = 'https://workflowy.com'
const API_BASE = `${API_ORIGIN}/api/v1`
const SECRET_API_KEY = '{{secret:WORKFLOWY_API_KEY|scope=user}}'
type JsonObject = Record<string, unknown>
type LayoutMode =
| 'bullets'
| 'todo'
| 'h1'
| 'h2'
| 'h3'
| 'code-block'
| 'quote-block'
type Position = 'top' | 'bottom'
type RequestOptions = {
method?: string
query?: Record<string, unknown>
body?: unknown
headers?: Record<string, string>
maxAttempts?: number
}
type NodeWriteInput = {
parentId?: string
parent_id?: string
name?: string
note?: string
layoutMode?: LayoutMode
position?: Position
}
type DispatcherInput = {
action?: string
path?: string
options?: RequestOptions
method?: string
query?: Record<string, unknown>
body?: unknown
headers?: Record<string, string>
maxAttempts?: number
id?: string
parentId?: string
parent_id?: string
name?: string
note?: string
layoutMode?: LayoutMode
position?: Position
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function requireString(value: unknown, name: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${name} is required.`)
}
return value.trim()
}
function optionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined
if (typeof value !== 'string') {
throw new Error('Expected a string value.')
}
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
function validateLayoutMode(value: LayoutMode | undefined): LayoutMode | undefined {
if (value === undefined) return undefined
const allowed: LayoutMode[] = [
'bullets',
'todo',
'h1',
'h2',
'h3',
'code-block',
'quote-block',
]
if (!allowed.includes(value)) {
throw new Error(
"layoutMode must be 'bullets', 'todo', 'h1', 'h2', 'h3', 'code-block', or 'quote-block'.",
)
}
return value
}
function validatePosition(value: Position | undefined): Position | undefined {
if (value === undefined) return undefined
if (value !== 'top' && value !== 'bottom') {
throw new Error("position must be 'top' or 'bottom'.")
}
return value
}
function parentIdOf(input: { parentId?: string; parent_id?: string }): string | undefined {
return optionalString(input.parentId ?? input.parent_id)
}
function retryDelayMs(retryAfter: string | null, attempt: number): number {
if (retryAfter) {
const seconds = Number(retryAfter)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const dateDelay = Date.parse(retryAfter) - Date.now()
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay)
}
return Math.min(5000, 500 * 2 ** (attempt - 1))
}
function parseResponse(text: string): unknown {
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function safeErrorDetail(value: unknown): string {
let detail = ''
if (typeof value === 'string') {
detail = value
} else if (value && typeof value === 'object') {
const object = value as Record<string, unknown>
const candidate = object.message ?? object.error ?? object.detail ?? object.status
detail = typeof candidate === 'string' ? candidate : JSON.stringify(value)
}
return detail
.replaceAll(SECRET_API_KEY, '[REDACTED]')
.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
.slice(0, 500)
}
function jsonBody(fields: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined))
}
/**
* Make an authenticated WorkFlowy REST request when a dedicated helper does not cover an endpoint.
*
* @param path - A WorkFlowy URL or path whose origin is `https://workflowy.com` and pathname begins with `/api/v1`.
* @param options - HTTP method, query values, JSON body, extra headers, and retry limit.
* @returns The parsed JSON response body.
*
* @example
* import { request } from 'kody:@kentcdodds/workflowy'
*
* const response = await request('/api/v1/targets')
*/
export async function request(path: string, options: RequestOptions = {}): Promise<unknown> {
const normalizedPath = requireString(path, 'path')
const url = new URL(normalizedPath, API_ORIGIN)
if (
url.origin !== API_ORIGIN ||
!(url.pathname === '/api/v1' || url.pathname.startsWith('/api/v1/'))
) {
throw new Error('WorkFlowy requests must stay on https://workflowy.com/api/v1.')
}
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value === undefined || value === null) continue
if (Array.isArray(value)) {
for (const item of value) url.searchParams.append(key, String(item))
} else {
url.searchParams.append(key, String(value))
}
}
const method = (options.method ?? 'GET').toUpperCase()
const body = options.body === undefined ? undefined : JSON.stringify(options.body)
const attempts = Math.max(1, Math.floor(options.maxAttempts ?? 3))
let lastError: Error | undefined
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const headers = new Headers(options.headers)
headers.set('accept', 'application/json')
headers.set('authorization', `Bearer ${SECRET_API_KEY}`)
if (body !== undefined) headers.set('content-type', 'application/json')
const response = await fetch(url, { method, headers, body })
const parsed = parseResponse(await response.text())
if (response.ok) return parsed
const detail = safeErrorDetail(parsed)
lastError = new Error(
`WorkFlowy API ${response.status} for ${url.pathname}${detail ? `: ${detail}` : ''}`,
)
if (!(response.status === 429 || response.status >= 500) || attempt >= attempts) {
throw lastError
}
await sleep(retryDelayMs(response.headers.get('retry-after'), attempt))
}
throw lastError ?? new Error('WorkFlowy request failed.')
}
/**
* List WorkFlowy targets (system locations and user shortcuts) when choosing a parent or destination.
*
* @returns `{ targets }` with `key`, `type`, and optional `name` for each target.
*
* @example
* import { listTargets } from 'kody:@kentcdodds/workflowy'
*
* const { targets } = await listTargets() as { targets: Array<{ key: string }> }
*/
export async function listTargets(): Promise<unknown> {
return await request('/api/v1/targets')
}
/**
* List child nodes under a parent when browsing an outline section.
*
* @param input - Optional `parentId` / `parent_id` (defaults to API root behavior when omitted).
* @returns `{ nodes }` unordered children — sort by `priority` yourself.
*
* @example
* import { listNodes } from 'kody:@kentcdodds/workflowy'
*
* const children = await listNodes({ parentId: 'inbox' })
*/
export async function listNodes(
input: { parentId?: string; parent_id?: string } = {},
): Promise<unknown> {
return await request('/api/v1/nodes', {
query: jsonBody({ parent_id: parentIdOf(input) }),
})
}
/**
* Retrieve one node by id, short id, or calendar target when inspecting a bullet.
*
* @param input - Node `id` (full UUID, short id, or calendar key such as `today`).
* @returns `{ node }` with WorkFlowy node fields.
*
* @example
* import { getNode } from 'kody:@kentcdodds/workflowy'
*
* const node = await getNode({ id: 'today' })
*/
export async function getNode(input: { id: string }): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}`)
}
/**
* Create a node under a parent when capturing a new bullet, todo, or header.
*
* @param input - Required `name` plus optional parent, note, layoutMode, and position.
* @returns `{ item_id }` for the created node (and children when multiline `name` is used).
*
* @example
* import { createNode } from 'kody:@kentcdodds/workflowy'
*
* const created = await createNode({
* parentId: 'inbox',
* name: 'Follow up with Pat',
* position: 'top',
* })
*/
export async function createNode(
input: NodeWriteInput & { name: string },
): Promise<unknown> {
const name = requireString(input?.name, 'name')
return await request('/api/v1/nodes', {
method: 'POST',
body: jsonBody({
parent_id: parentIdOf(input),
name,
note: optionalString(input.note),
layoutMode: validateLayoutMode(input.layoutMode),
position: validatePosition(input.position),
}),
})
}
/**
* Update selected fields on an existing node when revising name, note, or layout.
*
* @param input - Required `id` plus optional `name`, `note`, and `layoutMode`.
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { updateNode } from 'kody:@kentcdodds/workflowy'
*
* const result = await updateNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505', name: 'Revised title' })
*/
export async function updateNode(
input: {
id: string
name?: string
note?: string
layoutMode?: LayoutMode
},
): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}`, {
method: 'POST',
body: jsonBody({
name: optionalString(input.name),
note: optionalString(input.note),
layoutMode: validateLayoutMode(input.layoutMode),
}),
})
}
/**
* Permanently delete a node when it should be removed (cannot be undone).
*
* @param input - Node `id` to delete.
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { deleteNode } from 'kody:@kentcdodds/workflowy'
*
* const result = await deleteNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
*/
export async function deleteNode(input: { id: string }): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
/**
* Move a node under a new parent when reorganizing the outline.
*
* @param input - Node `id`, optional new `parentId` / `parent_id`, and `position`.
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { moveNode } from 'kody:@kentcdodds/workflowy'
*
* const result = await moveNode({
* id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505',
* parentId: 'today',
* position: 'top',
* })
*/
export async function moveNode(
input: {
id: string
parentId?: string
parent_id?: string
position?: Position
},
): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}/move`, {
method: 'POST',
body: jsonBody({
parent_id: parentIdOf(input),
position: validatePosition(input.position),
}),
})
}
/**
* Mark a node completed when finishing a todo or checklist item.
*
* @param input - Node `id` to complete.
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { completeNode } from 'kody:@kentcdodds/workflowy'
*
* const result = await completeNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
*/
export async function completeNode(input: { id: string }): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}/complete`, {
method: 'POST',
})
}
/**
* Clear completion on a node when reopening a finished item.
*
* @param input - Node `id` to uncomplete.
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { uncompleteNode } from 'kody:@kentcdodds/workflowy'
*
* const result = await uncompleteNode({ id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505' })
*/
export async function uncompleteNode(input: { id: string }): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}/uncomplete`, {
method: 'POST',
})
}
/**
* Create a live mirror of a node under another parent when the same content should appear in two places.
*
* @param input - Origin node `id`, destination full-node `parentId`, and optional `position`.
* @returns `{ item_id, origin_id }` for the new mirror and its origin.
*
* @example
* import { mirrorNode } from 'kody:@kentcdodds/workflowy'
*
* const mirror = await mirrorNode({
* id: '6ed4b9ca-256c-bf2e-bd70-d8754237b505',
* parentId: '5b401959-4740-4e1a-905a-62a961daa8c9',
* })
*/
export async function mirrorNode(
input: {
id: string
parentId?: string
parent_id?: string
position?: Position
},
): Promise<unknown> {
const id = requireString(input?.id, 'id')
const parentId = requireString(parentIdOf(input), 'parentId')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}/mirror`, {
method: 'POST',
body: jsonBody({
parent_id: parentId,
position: validatePosition(input.position),
}),
})
}
/**
* Delete a mirror root when removing a mirrored appearance without deleting the origin.
*
* @param input - Mirror node `id` (errors if the node is not a mirror).
* @returns `{ status: 'ok' }` on success.
*
* @example
* import { deleteMirror } from 'kody:@kentcdodds/workflowy'
*
* const result = await deleteMirror({ id: 'ee1ac4c4-775e-1983-ae98-a8eeb92b1aca' })
*/
export async function deleteMirror(input: { id: string }): Promise<unknown> {
const id = requireString(input?.id, 'id')
return await request(`/api/v1/nodes/${encodeURIComponent(id)}/mirror`, {
method: 'DELETE',
})
}
/**
* Export all nodes as a flat list when reconstructing the full outline tree.
* Rate-limited to 1 request per minute — use sparingly and project large results.
*
* @returns `{ nodes }` unordered flat list — rebuild hierarchy via `parent_id` and `priority`.
*
* @example
* import { exportNodes } from 'kody:@kentcdodds/workflowy'
*
* const { nodes } = await exportNodes() as { nodes: unknown[] }
* return { count: nodes.length }
*/
export async function exportNodes(): Promise<unknown> {
return await request('/api/v1/nodes-export')
}
/**
* Verify WorkFlowy authentication with one read-only targets request when checking package readiness.
*
* @returns Compact summary: `ok`, `targetCount`, and up to eight target `keys` (no huge trees).
*
* @example
* import { smokeTest } from 'kody:@kentcdodds/workflowy'
*
* const result = await smokeTest()
*/
export async function smokeTest(): Promise<{
ok: true
targetCount: number
keys: string[]
systemCount: number
shortcutCount: number
}> {
const response = (await listTargets()) as {
targets?: Array<{ key?: unknown; type?: unknown }>
}
const targets = Array.isArray(response.targets) ? response.targets : []
const keys = targets
.map((target) => (typeof target.key === 'string' ? target.key : null))
.filter((key): key is string => Boolean(key))
.slice(0, 8)
const systemCount = targets.filter((target) => target.type === 'system').length
const shortcutCount = targets.filter((target) => target.type === 'shortcut').length
return {
ok: true,
targetCount: targets.length,
keys,
systemCount,
shortcutCount,
}
}
/**
* Describe common WorkFlowy actions and operational limits when choosing how to use the dispatcher.
*
* @returns Package identity, common actions, and outline/mirror/export guidance.
*
* @example
* import { getOverview } from 'kody:@kentcdodds/workflowy'
*
* const overview = getOverview()
*/
export function getOverview(): {
name: string
description: string
baseUrl: string
commonActions: string[]
notes: string[]
} {
return {
name: '@kentcdodds/workflowy',
description:
'Secret-backed WorkFlowy REST helpers for targets, nodes, mirrors, and exports.',
baseUrl: API_BASE,
commonActions: [
'smokeTest',
'listTargets',
'listNodes',
'getNode',
'createNode',
'updateNode',
'moveNode',
'completeNode',
'uncompleteNode',
'mirrorNode',
'exportNodes',
],
notes: [
'Auth uses user secret WORKFLOWY_API_KEY as Authorization: Bearer.',
'parent_id accepts node ids, short ids, URLs, shortcuts, and calendar keys on most endpoints.',
'mirrorNode parent_id must be a full existing node id (not inbox/today/None).',
'exportNodes is limited to 1 request per minute.',
'deleteNode is permanent; prefer completeNode when marking work done.',
],
}
}
/**
* Dispatch WorkFlowy helpers by action name when an agent prefers one default callable export.
*
* @param input - An action plus that helper's parameters; defaults to `overview`.
* @returns The selected helper result or package overview.
*
* @example
* import workflowy from 'kody:@kentcdodds/workflowy'
*
* const targets = await workflowy({ action: 'listTargets' })
*/
export default async function workflowy(input: DispatcherInput = {}): Promise<unknown> {
const action = input.action ?? 'overview'
switch (action) {
case 'overview':
case 'getOverview':
return getOverview()
case 'smokeTest':
return await smokeTest()
case 'request':
return await request(requireString(input.path, 'path'), {
method: input.options?.method ?? input.method,
query: input.options?.query ?? input.query,
body: input.options?.body ?? input.body,
headers: input.options?.headers ?? input.headers,
maxAttempts: input.options?.maxAttempts ?? input.maxAttempts,
})
case 'listTargets':
return await listTargets()
case 'listNodes':
return await listNodes(input)
case 'getNode':
return await getNode({ id: requireString(input.id, 'id') })
case 'createNode':
return await createNode({
parentId: input.parentId,
parent_id: input.parent_id,
name: requireString(input.name, 'name'),
note: input.note,
layoutMode: input.layoutMode,
position: input.position,
})
case 'updateNode':
return await updateNode({
id: requireString(input.id, 'id'),
name: input.name,
note: input.note,
layoutMode: input.layoutMode,
})
case 'deleteNode':
return await deleteNode({ id: requireString(input.id, 'id') })
case 'moveNode':
return await moveNode({
id: requireString(input.id, 'id'),
parentId: input.parentId,
parent_id: input.parent_id,
position: input.position,
})
case 'completeNode':
return await completeNode({ id: requireString(input.id, 'id') })
case 'uncompleteNode':
return await uncompleteNode({ id: requireString(input.id, 'id') })
case 'mirrorNode':
return await mirrorNode({
id: requireString(input.id, 'id'),
parentId: input.parentId,
parent_id: input.parent_id,
position: input.position,
})
case 'deleteMirror':
return await deleteMirror({ id: requireString(input.id, 'id') })
case 'exportNodes':
return await exportNodes()
default:
throw new Error(`Unknown workflowy action: ${action}`)
}
}