# Hjarni REST API

The REST API is for scripts, automations, and custom integrations. If you are connecting an AI assistant like ChatGPT or Claude, use the [MCP server](https://hjarni.com/docs/mcp) instead.

## Overview

- **Base URL**: `https://hjarni.com/api/v1`
- **Auth**: Bearer token
- **Content type**: `application/json`
- **Resources**: Me, dashboard, search, notes (including file attachments), containers, and tags
- **Rate limit**: 100 requests per minute per token. Exceeding it returns `429` with a `Retry-After` header.

## Authentication

1. Go to **Settings > Connections** in Hjarni.
2. Create a token for your integration.
3. Send it in the `Authorization` header as a Bearer token.

```
curl https://hjarni.com/api/v1/notes \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

## Pagination

List endpoints accept `page` (default 1) and `per_page` (default 25, max 100). Two exceptions return the full list without pagination: `GET /containers/:id/children` and `GET /notes/:id/linked`. Metadata is returned in response headers:

- **`X-Total-Count`**: Total number of records matching the query
- **`X-Page`**: Current page number
- **`X-Per-Page`**: Items per page

## Me

GET `/api/v1/me`

The account behind the token: user and plan, onboarding state, team memberships, and note limits, plus a `token` block describing the token itself. Folder-scoped tokens get only the `user` and `token` blocks.

**Response example**

```
{
  "user": {"id": 1, "first_name": "Evert", "email_address": "you@example.com", "plan": "pro"},
  "token": {"id": 7, "name": "smoke-test", "scope_container": null},
  "onboarding": {"completed": true, "missing_steps": [], "signals": {...}},
  "teams": [{"id": 3, "name": "Acme", "role": "owner", "notes_used": 12, "notes_limit": 25}],
  "limits": {"notes_used": 42, "notes_limit": null},
  "suggestions": {"create_team": false}
}
```

## Dashboard

GET `/api/v1/dashboard`

Overview of your personal knowledge base with counts and recent notes.

**Response example**

```
{
  "inbox_count": 3,
  "notes_count": 42,
  "archived_count": 7,
  "containers_count": 8,
  "tags_count": 12,
  "recent_notes": [
    {
      "id": 1,
      "title": "Weekly review",
      "body": "# Decisions\n\n...",
      "summary": "Review of the week",
      "summary_stale": false,
      "source_url": null,
      "archived": false,
      "favorited": false,
      "position": 1,
      "lock_version": 2,
      "created_at": "2026-03-15T10:00:00Z",
      "updated_at": "2026-03-15T10:00:00Z",
      "tag_list": "review,planning",
      "tags": [{"id": 1, "name": "review"}, {"id": 2, "name": "planning"}],
      "container": {"id": 5, "name": "Work"},
      "files": []
    }
  ]
}
```

## Search

GET `/api/v1/search`

Full-text search across notes. Returns paginated note objects.

- **`q`**: Search query string. Omitting it (or sending it blank) returns the unfiltered scope.
- **`search_scope`**: `"personal"`, `"all"` (default), or `"team:<id>"` for a specific team.
- **`page`, `per_page`**: Pagination. Defaults to page 1, 25 per page.
- **`exclude_body`**: `true` omits the `body` field from each result. Fetch full notes individually with `GET /notes/:id`.

```
GET /api/v1/search?q=weekly+planning&search_scope=personal
```

## Notes

GET `/api/v1/notes`

List personal notes. Paginated.

- **`scope`**: `"archived"`, `"inbox"`, `"favorited"`, or omit for active notes. Trashed notes have their own endpoint: `GET /notes/trash`.
- **`container_id`**: Filter by container.
- **`tag`**: Filter by tag name.
- **`q`**: Full-text search within listed notes.
- **`page`, `per_page`**: Pagination.
- **`exclude_body`**: `true` omits the `body` field from each note, so sync clients can page through metadata cheaply and fetch full notes one at a time with `GET /notes/:id`.

GET `/api/v1/notes/inbox`

Notes without a container. Paginated. Same response shape as listing notes, including `exclude_body`.

GET `/api/v1/notes/:id`

Full note with body, tags, container, files, and linked note IDs.

Each file's `url` is a signed link that expires 15 minutes after the response is generated, at the time given in `url_expires_at`. It needs no token of its own, so treat it as a credential and don't store it: re-request the note for a fresh link rather than saving the URL. A sync client that pages through notes first and downloads attachments afterwards should fetch the note again at download time.

**Response example**

```
{
  "id": 1,
  "title": "Weekly review",
  "body": "# Decisions\n\nSee [[42:Project plan]].",
  "summary": "Review of the week",
  "summary_stale": false,
  "source_url": "https://example.com/source",
  "archived": false,
  "favorited": true,
  "position": 1,
  "lock_version": 4,
  "created_at": "2026-03-15T10:00:00Z",
  "updated_at": "2026-03-15T12:30:00Z",
  "tag_list": "review,planning",
  "tags": [
    {"id": 1, "name": "review"},
    {"id": 2, "name": "planning"}
  ],
  "container": {"id": 5, "name": "Work"},
  "files": [
    {
      "id": 10,
      "description": "Slide deck",
      "filename": "slides.pdf",
      "content_type": "application/pdf",
      "byte_size": 204800,
      "url": "https://hjarni.com/rails/active_storage/blobs/...",
      "url_expires_at": "2026-03-15T12:45:00Z"
    }
  ],
  "linked_note_ids": [42, 58]
}
```

POST `/api/v1/notes`

Create a note. Returns `201` on success.

- **`title`**: Required. Note title.
- **`body`**: Markdown content. Supports `[[id:Title]]` wiki-links.
- **`summary`**: Short summary for quick scanning.
- **`source_url`**: Canonical source URL.
- **`container_id`**: Place the note in a container. Omit for inbox.
- **`tag_list`**: Comma-separated tag names, e.g. `"review,planning"`.
- **`position`**: Sort position within the container. Omit to leave unpositioned; unpositioned notes sort before positioned ones.

```
POST /api/v1/notes
{
  "note": {
    "title": "Weekly review",
    "body": "# Weekly review\n\nKey decisions...",
    "summary": "Review of the week",
    "source_url": "https://example.com/source",
    "container_id": 12,
    "tag_list": "review,planning"
  }
}
```

PATCH `/api/v1/notes/:id`

Update any note field. Only include the fields you want to change.

```
PATCH /api/v1/notes/1
{
  "note": {
    "body": "Updated content",
    "tag_list": "review,done"
  }
}
```

Optional `expected_lock_version` guards against concurrent edits. Every note read returns a `lock_version` integer. Echo it back on your write and the server rejects with `409 Conflict` if someone else saved in the meantime.

```
PATCH /api/v1/notes/1
{
  "note": {
    "body": "Updated content",
    "expected_lock_version": 5
  }
}

# 409 Conflict response when the version no longer matches:
{
  "error": "Note was modified since you last read it. Re-read and re-apply...",
  "current_lock_version": 7,
  "last_edited_by": "alice@example.com",
  "last_edited_by_agent": null,
  "last_edited_at": "2026-05-27T14:32:00Z"
}
```

Surgical body edits are anchor-based and stay safe with or without the version: `append_body`, `replace_find` (with `replace_with`, which deletes the match when omitted), and `insert_body` (with `insert_after` or `insert_before` naming the anchor line). Pass `expected_lock_version` whenever you also touch non-body fields and want a stale-write guard.

For long notes, prefer the structured patch operations over a full-body replacement — they target a heading or checklist item instead of the whole document, so a stale read can't clobber unrelated sections. Each targets exactly one match and is mutually exclusive with the other body modes.

- **`replace_section` + `section_body`**: Replace everything under a heading (with or without leading `#`), keeping the heading line. The section runs to the next same-or-higher-level heading.
- **`append_to_section` + `section_body`**: Add content at the end of a section — the safe way to insert under a heading without knowing its last line.
- **`rename_heading` + `new_heading`**: Rename a heading in place. The level is preserved unless `new_heading` carries its own `#` markers.
- **`check_item` / `uncheck_item`**: Tick or untick a checklist item by its text (e.g. `ship it`). Matched case- and whitespace-insensitively.

```
PATCH /api/v1/notes/1
{
  "note": {
    "append_to_section": "## Open Questions",
    "section_body": "- Should we ship Friday?"
  }
}
```

DELETE `/api/v1/notes/:id`

Move a note to Trash. Returns `204`. The note is recoverable — it stays restorable until it is purged, and can be brought back with `POST /notes/:id/restore` (see History & Trash below).

### Note actions

`PATCH /notes/:id/archive`

Archive a note. Returns the updated note.

`PATCH /notes/:id/unarchive`

Restore an archived note. Returns the updated note.

`PATCH /notes/:id/favorite`

Mark a note as favorited. Returns the updated note.

`PATCH /notes/:id/unfavorite`

Remove from favorites. Returns the updated note.

`PATCH /notes/:id/move`

Move to another container. Send `{"container_id": 5}`.

### Note links

`POST /notes/:id/link`

Link two notes bidirectionally. Send `{"target_note_id": 42}`. Returns the source note.

`DELETE /notes/:id/unlink`

Remove the link. Send `{"target_note_id": 42}`. Returns `204`.

`GET /notes/:id/linked`

List all notes linked to this note. Returns an array of note objects.

### Note files

POST `/api/v1/notes/:id/files`

Attach a file to a note by URL. Hjarni fetches the URL (following redirects) and stores the result. Free accounts can attach up to 20 MB across five personal attachments; uploads beyond that allowance get `403` with an `upgrade_url`. Returns `201`.

- **`url`**: Required. The file to fetch. Missing or blank returns `422` `{"error": "url is required"}`.
- **`filename`, `content_type`**: Optional overrides; otherwise derived from the URL and response headers.
- **`description`**: Optional description shown with the attachment.

```
POST /api/v1/notes/1/files
{"url": "https://example.com/slides.pdf", "description": "Slide deck"}

# 201 response:
{"id": 10, "filename": "slides.pdf", "content_type": "application/pdf",
 "byte_size": 204800, "description": "Slide deck"}
```

`POST /notes` and `PATCH /notes/:id` also accept a nested `note_files_attributes` array for attaching as part of a create or update.

### History & Trash

Every note keeps an append-only revision history, attributed to whoever made each edit (you, an AI agent, or the system). Deletes are recoverable from Trash until they are purged. See [Note history and Trash](https://hjarni.com/docs/note-history) for the concepts behind these endpoints.

`GET /notes/:id/history`

List revisions, newest first. Each entry carries `seq`, `at`, a one-line `summary`, what `changed`, and a `by` block (`label`, `kind`, `client`, `agent`, `source`). Accepts `limit` (1–100, default 50), `before_seq` for paging, and `include_body=true` to inline each reconstructed body.

`GET /notes/:id/history?seq=N`

Reconstruct one revision in full, including its `body` as it stood at that point.

`POST /notes/:id/revert`

Revert the note to an earlier revision. Send `{"seq": N}`. This writes a new revision (it never rewrites history) with `reverted_from_seq` set. Optional `expected_lock_version` rejects with `409 Conflict` if the note changed since you read it. Returns the updated note.

`GET /notes/trash`

List trashed notes, most recently deleted first. Each entry adds `deleted_at`, `purge_after`, and `deleted_by`.

`POST /notes/:id/restore`

Bring a trashed note back. Returns the restored note. A note that was deleted together with its folder is not restorable on its own (`404`): restore the folder instead and the note comes back with it.

## Containers

GET `/api/v1/containers`

List containers. Paginated. Returns root-level containers by default.

- **`scope`**: `"archived"`, `"all"` (all active, flat), or omit for active roots only.

**Response example**

```
[
  {
    "id": 5,
    "name": "Work",
    "description": "Day job projects",
    "position": 1,
    "archived": false,
    "root": false,
    "llm_instructions": "Use formal tone.",
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-15T10:00:00Z",
    "notes_count": 12,
    "children_count": 3,
    "parent": null
  }
]
```

GET `/api/v1/containers/:id`

Single container with counts and parent info.

POST `/api/v1/containers`

Create a container. Returns `201`.

- **`name`**: Required. Container name.
- **`description`**: Optional description.
- **`parent_id`**: Nest under a parent container. Omit for root.
- **`llm_instructions`**: AI instructions scoped to this container.
- **`position`**: Sort position among siblings. Omit to leave unpositioned; unpositioned containers sort before positioned ones.

```
POST /api/v1/containers
{
  "container": {
    "name": "Research",
    "description": "Papers and reading notes",
    "parent_id": 5
  }
}
```

PATCH `/api/v1/containers/:id`

Update name, description, parent, position, or instructions.

DELETE `/api/v1/containers/:id`

Move a container to the Trash, taking its sub-folders and their notes with it as one recoverable unit. Restorable for 30 days via `PATCH /containers/:id/restore`, then permanently removed. Returns `204`, or `409` if the container was already deleted by a concurrent request.

### Container actions

`PATCH /containers/:id/archive`

Archive. Optional `include_notes=true` also archives every note in the folder in one bulk update, and `include_nested=true` sweeps nested sub-folders too. Returns the updated container plus `containers_changed` and `notes_changed` counts.

`PATCH /containers/:id/restore`

Bring a deleted container back from the Trash, together with everything its delete took. Returns the restored container. A container that is not in the Trash answers `404`; `409` means it left the Trash mid-request (restored elsewhere, or purged).

`PATCH /containers/:id/unarchive`

Restore. Takes the same `include_notes` and `include_nested` options, which reactivate everything they touch, including items archived individually before. Restoring a nested folder also reactivates its archived ancestors so it stays reachable. Returns the updated container plus the change counts.

`GET /containers/:id/children`

Direct child containers. Returns an array of container objects.

`GET /containers/:id/notes`

Notes in this container. Paginated.

`GET /containers/:id/tree`

Container with its ancestor chain and direct children.

`PATCH /containers/reorder`

Reorder containers. Send `{"ordered_ids": [3,1,2]}` with all IDs in the scope. Optional `parent_id` to reorder children of a specific container. Returns `204`.

### Root instructions

`GET /containers/root_instructions`

Get your personal root AI instructions. Returns `{"personal_llm_instructions": "..."}`.

`PATCH /containers/update_root_instructions`

Update root instructions. Send `{"personal_llm_instructions": "..."}`.

## Tags

GET `/api/v1/tags`

List all tags with note counts. Paginated.

**Response example**

```
[
  {
    "id": 1,
    "name": "review",
    "created_at": "2026-03-01T09:00:00Z",
    "updated_at": "2026-03-15T10:00:00Z",
    "notes_count": 8
  }
]
```

GET `/api/v1/tags/:id`

Single tag with note count.

POST `/api/v1/tags`

Create a tag. Returns `201`.

```
POST /api/v1/tags
{
  "tag": {"name": "research"}
}
```

PATCH `/api/v1/tags/:id`

Rename a tag. Send `{"tag": {"name": "new-name"}}`.

DELETE `/api/v1/tags/:id`

Delete a tag. Returns `204`. Notes keep their content; only the tag association is removed.

### Tag actions

`PATCH /tags/:id/merge`

Merge this tag into another. Send `{"target_id": 5}`. All notes are moved to the target tag and this tag is deleted. Returns the target tag.

`GET /tags/:id/notes`

List notes with this tag. Paginated. Returns note objects.

## Errors

`401`

Missing or invalid token.

`403`

Two cases. Plan-gated actions (exceeding the Free plan note limit, file uploads beyond the free attachment allowance) return an `error` message plus `upgrade_url`. Folder-scoped tokens reaching outside their scope (inbox, tag writes, other folders) return `{"error": "Forbidden: outside token scope"}` with no `upgrade_url`.

`404`

Resource not found or not accessible by your account.

`422`

Validation failed. Model validations return an `errors` array with messages; parameter checks (e.g. `reorder`'s `ordered_ids`, a missing file `url`) return a single `error` string.

`429`

Rate limited: more than 100 requests in a minute on one token. Response is JSON with an `error` message and a `Retry-After` header saying when to try again.

Questions about the API?

Email [evert@hjarni.com](mailto:evert@hjarni.com) and we'll help.
