Skip to main content
Glama
README.md
# Logseq AI

AI-powered interactions with Logseq through two interfaces:

1. **MCP Server** - Enables external AI tools (Windsurf, Claude Desktop) to interact with your Logseq graph
2. **Lain** - Embedded AI assistant plugin within the Logseq UI

> "No matter where you go, everyone's connected." - Serial Experiments Lain

## Prerequisites

- Node.js 18+
- **Logseq 2.x with a DB graph.** File graphs (Logseq 1.x) are not supported —
  they use the older `:block/content` / `:block/marker` schema. The tools detect
  the graph type and fail with a clear message rather than returning empty
  results.
- HTTP API server enabled (Settings → Features → HTTP APIs server)

> **Keep the Logseq window visible while the MCP server is in use.** When the
> window is minimized or on another virtual desktop, Chromium throttles the
> timers Logseq's request queue depends on, and write calls hang until it
> regains focus. See [doc/logseq-api-notes.md](doc/logseq-api-notes.md).

### Task model in 2.x

Logseq 2.x replaced text markers with properties, so the task tools use
Logseq's native vocabulary:

| | Values |
|---|---|
| **Status** | `Backlog`, `Todo`, `Doing`, `In Review`, `Done`, `Canceled` |
| **Priority** | `Low`, `Medium`, `High`, `Urgent` |

Legacy spellings are still accepted on input and normalized (`TODO` → `Todo`,
`A` → `High`), so existing prompts keep working. Writing `"TODO buy milk"` as
block *text*, however, no longer creates a task — use `create_task`.

## Setup

```bash
# Install dependencies
pnpm install

# Build all packages
pnpm build
```

With [just](https://github.com/casey/just) installed, `just install` and
`just build` do the same; see [Development](#development) for the full list.

## Quick Start

### Using the Core Library

```typescript
import { LogseqClient, LogseqOperations } from "@logseq-ai/core";

// Create client and operations
const client = new LogseqClient({
  baseUrl: "http://localhost:12315",
  token: "your-api-token" // optional
});
const ops = new LogseqOperations(client);

// Search for pages
const results = await ops.search("meeting notes");

// Get page content as text
const content = await ops.getPageContent("My Notes");

// Create a new block
const block = await ops.createBlock({
  pageName: "My Notes",
  content: "New idea from AI!"
});

// Create multiple blocks with hierarchy (more efficient for structured content)
const result = await ops.createBlocks("My Notes", [
  {
    content: "## Project Overview",
    children: [
      { content: "Goal: Build a new feature" },
      { content: "Timeline: Q1 2025" },
    ]
  },
  {
    content: "## Tasks",
    children: [
      { content: "Design the API" },
      { content: "Implement backend" },
      { content: "Write tests" },
    ]
  }
]);
console.log(`Created ${result.created} blocks`);

// Run a Datalog query to find tasks.
// DB graphs store block text in :block/title; :block/content and
// :block/marker no longer exist.
const tasks = await ops.query(`
  [:find (pull ?b [:block/uuid :block/title])
   :where
   [?b :block/tags ?t]
   [?t :db/ident :logseq.class/Task]]
`);
```

### Task Management

```typescript
import { LogseqClient, LogseqOperations } from "@logseq-ai/core";

const client = new LogseqClient();
const ops = new LogseqOperations(client);

// Get open tasks (Backlog, Todo, Doing, In Review)
const tasks = await ops.getTasks();

// Or filter to specific statuses
const inFlight = await ops.getTasks({ statuses: ["Doing", "In Review"] });

// Get tasks from a specific page
const projectTasks = await ops.getTasks({ pageName: "Project A" });

// Create a task. In a DB graph this is a block tagged with Logseq's Task
// class - do NOT prefix the content with "TODO", that is just literal text.
const task = await ops.createTask({
  pageName: "Project A",
  content: "Review pull request",
  priority: "High",       // Low | Medium | High | Urgent
  deadline: "2026-08-15"
});

// Move it through the workflow
await ops.markTask(task.uuid, "Doing");
await ops.markTask(task.uuid, "In Review");
await ops.markTask(task.uuid, "Done");

// Set/change priority
await ops.setTaskPriority(task.uuid, "Medium");

// Set scheduled date
await ops.setTaskScheduled({ uuid: task.uuid, date: "2026-08-10" });

// Remove deadline
await ops.setTaskDeadline({ uuid: task.uuid, date: null });
```

### Task Analytics

```typescript
// Get overdue tasks
const overdue = await ops.getOverdueTasks();
console.log(`You have ${overdue.length} overdue tasks`);

// Get tasks due in the next 7 days
const dueSoon = await ops.getTasksDueSoon({ days: 7 });

// Get task statistics
const stats = await ops.getTaskStats();
console.log(`Total: ${stats.total}, Overdue: ${stats.overdue}`);
console.log(`By status:`, stats.byStatus);

// Search tasks by keyword
const reviewTasks = await ops.searchTasks({ query: "review", statuses: ["Todo"] });
```

### Journal Operations

```typescript
// Get or create today's journal
const today = await ops.getToday();
console.log(`Today's journal: ${today.page.title}`);

// Quick capture to today's journal
await ops.appendToToday("Meeting notes: discussed Q1 roadmap");

// Get recent journal entries
const journals = await ops.getRecentJournals({ days: 7, includeContent: true });
journals.forEach(j => console.log(`${j.date}: ${j.content.substring(0, 50)}...`));
```

### Page Links & Backlinks

```typescript
// Find pages related to a topic
const links = await ops.findRelatedPages("Project A");
console.log("Pages linking to Project A:", links.backlinks);
console.log("Pages Project A links to:", links.forwardLinks);

// Find blocks referencing a specific block
const backlinks = await ops.getBlockBacklinks("block-uuid-123");
console.log(`Found ${backlinks.backlinks.length} references`);
```

### Error Handling

```typescript
import { 
  LogseqOperations, 
  LogseqApiError, 
  LogseqNotFoundError,
  isLogseqError 
} from "@logseq-ai/core";

try {
  await ops.getPageContent("Nonexistent Page");
} catch (error) {
  if (error instanceof LogseqNotFoundError) {
    console.log(`Page not found: ${error.identifier}`);
  } else if (isLogseqError(error)) {
    console.log(`Logseq error: ${error.toDetailedString()}`);
  }
}
```

## Packages

### @logseq-ai/core

Shared library for Logseq API interactions. Used by both the MCP server and plugin.

**Key exports:**
- `LogseqClient` - Low-level HTTP client for Logseq API
- `LogseqOperations` - High-level operations with error handling
- Error classes: `LogseqError`, `LogseqApiError`, `LogseqConnectionError`, `LogseqNotFoundError`, `LogseqValidationError`
- Type definitions: `Page`, `Block`, `SearchResult`, etc.

### @logseq-ai/mcp-server

MCP server that exposes Logseq operations as tools for AI clients.

**Features:**
- 43 tools for comprehensive Logseq interaction
- Input validation with clear error messages (powered by Zod)
- Proper error handling for Logseq API errors

```bash
# Build
pnpm --filter @logseq-ai/mcp-server build

# Run
LOGSEQ_API_TOKEN=your-token pnpm --filter @logseq-ai/mcp-server start

# Test
pnpm --filter @logseq-ai/mcp-server test
```

#### Configure in Windsurf/Claude Desktop

Add to your MCP configuration:

```json
{
  "mcpServers": {
    "logseq": {
      "command": "node",
      "args": ["/path/to/logseq-ai/packages/mcp-server/dist/index.js"],
      "env": {
        "LOGSEQ_API_URL": "http://localhost:12315",
        "LOGSEQ_API_TOKEN": "your-token"
      }
    }
  }
}
```

| Variable | Default | Description |
|----------|---------|-------------|
| `LOGSEQ_API_URL` | `http://localhost:12315` | Base URL of the Logseq HTTP API server |
| `LOGSEQ_API_TOKEN` | _(none)_ | Authorization token |
| `LOGSEQ_MAX_RETRIES` | `2` | Retries for transient failures; `0` disables them |

The client retries transient failures with exponential backoff and jitter.
Connection failures are retried for any call, since the request never reached
Logseq. Timeouts — including the empty response Logseq returns when its window
is backgrounded — are retried for reads only: a mutation may have been applied
before the response was lost, so replaying it could duplicate a block or page.
Logseq handler errors are never retried.

A write that times out is reported as **uncertain**, not failed. Logseq cannot
cancel a handler that is already running, so a timed-out write can commit
seconds or minutes later — retrying it blindly is how one appended block becomes
two. Where a cheap read-back exists, the error says what is actually there
("a page titled X now exists…", "2 blocks with this exact text are already on
Y…"). A tree write that fails partway names the blocks that already landed, so
the fix is to repair the page rather than write it again.

#### Available Tools

**Page & Block Operations**
| Tool | Description |
|------|-------------|
| `search_logseq` | Search for pages and blocks |
| `get_page` | Get page content as plain text (`includeUuids` adds each block's uuid for editing) |
| `get_block` | Get one block and its children by uuid, with uuids attached |
| `find_blocks` | Find blocks by text, page, and/or tag - structured, escaped, bin-filtered |
| `get_pages` | Get multiple pages in a batch (more efficient) |
| `get_page_with_context` | Get page with backlinks and forward links |
| `list_pages` | List all pages in the graph |
| `create_page` | Create a new page (optionally with blocks) |
| `delete_page` | Delete a page (moves it to the recycle bin) |
| `rename_page` | Rename a page; references follow automatically |
| `list_deleted_pages` | List pages in the recycle bin |
| `restore_page` | Restore a page from the recycle bin |
| `create_block` | Create a single block |
| `create_blocks` | Create multiple blocks with hierarchy, on a page or under a block |
| `update_block` | Update a block's content |
| `delete_block` | Delete a block |
| `query_logseq` | Run Datalog queries |
| `update_page_properties` | Update properties on an existing page |

**Graph Discovery**
| Tool | Description |
|------|-------------|
| `get_current_graph` | Get current graph info |
| `get_graph_stats` | Get graph statistics (pages by type, orphans, etc.) |
| `find_missing_pages` | Find referenced pages that don't exist |
| `find_orphan_pages` | Find pages with no incoming links |
| `find_pages_by_properties` | Find pages by property values |
| `find_related_pages` | Find backlinks and forward links |
| `get_block_backlinks` | Find blocks referencing a block |

**Journal Operations**
| Tool | Description |
|------|-------------|
| `get_today` | Get today's journal page |
| `append_to_today` | Add content to today's journal |
| `get_recent_journals` | Get recent journal entries |

**Task Management**
| Tool | Description |
|------|-------------|
| `get_tasks` | Get open tasks (Backlog/Todo/Doing/In Review) |
| `create_task` | Create a new task |
| `mark_task` | Change task status |
| `mark_tasks` | Change multiple tasks' status (batch) |
| `search_tasks` | Search tasks by keyword |
| `get_overdue_tasks` | Get tasks past deadline |
| `get_tasks_due_soon` | Get tasks due within N days |
| `get_task_stats` | Get task statistics |
| `set_task_priority` | Set task priority (Low/Medium/High/Urgent) |
| `set_task_deadline` | Set task deadline |
| `set_task_scheduled` | Set task scheduled date |

#### Example: Creating Structured Content

When creating pages with multiple sections, use `create_blocks` for efficiency:

```
User: Create a page about Python with sections for Overview, Features, and Links

AI uses:
1. create_page({ pageName: "Python", tags: ["Technology"], properties: { Topic: "Programming" } })
2. create_blocks({
     pageName: "Python",
     blocks: [
       {
         content: "## Overview",
         children: [
           { content: "Python is a high-level programming language." }
         ]
       },
       {
         content: "## Features",
         children: [
           { content: "Dynamic typing" },
           { content: "Garbage collection" }
         ]
       },
       {
         content: "## Links",
         children: [
           { content: "[Official Site](https://python.org)" }
         ]
       }
     ]
   })
```

To extend a section later, pass `parentBlockUuid` instead of `pageName` — the
tree lands under that block rather than at the top level of the page. Both
`create_block` and `create_blocks` take exactly one of the two; passing both is
an error rather than a silently ignored argument:

```
create_blocks({
  parentBlockUuid: "<uuid of the '## Features' block>",
  blocks: [{ content: "Large standard library" }]
})
```

#### Block Content Rules

Every tool that writes block text (`create_block`, `create_blocks`,
`create_page`, `update_block`, `append_to_today`, `create_task`, and template
variable values) validates it first. Logseq accepts unsupported markup and
stores it verbatim, so these mistakes succeed silently and corrupt the graph
quietly; the MCP server rejects them with a message naming the fix instead:

- **One logical unit per block.** No markdown list markers (`- `, `1. `), no
  second heading, no heading mixed with body text — Logseq is an outliner and
  renders one bullet per block. Use `children` for structure.
- **No file-graph task markup.** A `TODO ` prefix makes an ordinary block, not
  a task; `[#A]` creates a tag literally named `A]`. Use `create_task`,
  `mark_task` and `set_task_priority`.
- **No `key:: value` text.** DB graphs store properties as structured fields —
  use the `properties` argument or `update_page_properties`.
- **No `[[Page|label]]`** (a Roam convention) and no unbalanced `[[`. Write
  `[[Page]]`, or `[label]([[Page]])` for different link text.

Fenced code blocks are exempt — inside them the markup is meant literally.
Multi-line prose is fine; only list and heading *structure* has to be split
across blocks.

#### Page References

Write references as `[[Page Title]]`. A DB graph stores them by uuid, so before
writing, each title is resolved to a page — created once if it does not exist
yet — and rewritten to `[[uuid]]`. Letting Logseq auto-create reference targets
per insert is what produced duplicate pages for the same title.

Resolving up front also lets a whole block tree go in as a single request, so a
timeout cannot leave a half-built page behind. If a title cannot be resolved,
that tree falls back to one insert per block, which resolves references itself
at the cost of atomicity.

Reads go the other way: `get_page` and friends render `[[uuid]]` back as
`[[Page Title]]`, resolving every reference on the page in one query. A uuid
whose page cannot be named is left as-is rather than dropped.

#### Editing What You Read

Every edit tool addresses blocks by uuid, so reads can hand you the uuids
directly: pass `includeUuids: true` to `get_page` and each line comes back as
`- text ((uuid))`, ready for `update_block`, `delete_block`, or
`create_blocks({ parentBlockUuid })`. `get_block` re-reads a single subtree by
uuid (uuids always included), and `find_blocks` locates blocks by text, page,
and/or tag without hand-writing Datalog - values are escaped and recycle-bin
content filtered automatically.

#### The Recycle Bin

`delete_page` never erases: Logseq stamps the page `deleted-at` and hides it,
and the title cannot be reused while the page sits in the bin. `list_deleted_pages`
shows what is in there, `restore_page` brings a page back (verified by re-reading,
since Logseq reports success even for property writes it discards), and
`rename_page` frees a title without deleting anything. Rename and restore both
refuse conflicting titles - live or recycled - rather than creating duplicates.

### logseq-lain

Lain - AI assistant plugin for Logseq.

```bash
# Build
pnpm --filter logseq-lain build

# Development (watch mode)
pnpm dev:plugin
```

#### Install in Logseq

1. Enable Developer Mode in Logseq (Settings → Advanced → Developer mode)
2. Go to Plugins → Load unpacked plugin
3. Select the `packages/logseq-plugin` directory
4. Use `/lain ask`, `/lain summarize`, `/lain expand` slash commands

## Architecture

See [doc/architecture.md](doc/architecture.md) for detailed architecture documentation.

## Development

Common tasks are wrapped in a [justfile](justfile). Run `just` to see them all.

```bash
just check          # format-check, lint, typecheck, test — everything CI enforces
just test           # run all test suites (489 tests)
just typecheck      # tsc across all three packages
just lint           # eslint
just format-check   # prettier, without writing
just coverage       # tests with a coverage report
just fix            # apply formatting and fixable lint rules, then re-check
just build          # compile every package
```

Each recipe is a thin wrapper over the equivalent `pnpm` script, so
`pnpm test`, `pnpm typecheck` and friends still work on their own.

`just check` orders its steps cheapest-failure-first, so a formatting slip
surfaces before the test suite runs.

### Test layout

| Package      | Tests | Covers                                                          |
| ------------ | ----- | --------------------------------------------------------------- |
| `core`       | 256   | Client transport, DB idents, operations, tasks, templates, errors |
| `mcp-server` | 233   | Tool registry wiring, Zod validation, handler dispatch and output |

The `mcp-server` tests run against a stubbed `LogseqOperations`, so they need
no running Logseq. The tool-registry tests assert that every advertised tool
resolves to a handler — a tool without one is otherwise invisible until a
client calls it and gets `Unknown tool`.

## License

MIT