~bigbes/agents-dev · parade

main · last commit 13 days ago · 7g0stsfu

← Back to the parade

ah-gxa internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth) Past Stand

status: closed P2 feature @Eugene Blikh
bd reopen ah-gxa
Created byEugene Blikh
Ownerbigbes@gmail.com
Created2026-07-18T15:03:36Z
Started2026-07-18T15:06:56Z
Updated2026-07-18T15:59:07Z
Closed2026-07-18T15:59:07Z
Description
Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q&A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges.
Design
# internal/yonote — light API client (stdlib-only, mem0 style)

Mirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client`
(trailing "/" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods,
one `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go
pinning the contract + quirks, httptest unit tests. No third-party deps.

Contract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18.
Yonote = Outline fork; API is RPC-style `POST /api/<resource>.<verb>` (JSON body) PLUS a newer
`/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer <token>` (tokens are
JWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`.
Error envelope `{ok:false, error:"<machine_code>", status:<int, SOMETIMES ABSENT>, message:"<human>"}`
— key off the HTTP status, carry error/message.

## Endpoint table (methods to implement; L = live-verified, S = spec-only)

| Method                                   | HTTP + path                                | Request                                            | Response essentials |
|------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------|
| AuthInfo(ctx)                            | POST /api/auth.info                        | {}                                                 | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} |
| DocumentInfo(ctx, id)                    | POST /api/documents.info                   | {"id"} — uuid OR urlId OR slug-urlId (all L)       | L data = Document |
| ExportMarkdown(ctx, uuid)                | GET  /api/v2/documents/{uuid}/markdown     | path uuid ONLY (urlId → 400 invalid_format, L)     | L {"data":"<markdown string>"} — may be "" (see quirks) |
| SearchDocuments(ctx, req{Query,Limit})   | POST /api/documents.search                 | {"query","limit"}                                  | L data[] of {context string, ranking float64, document Document}; top-level count |
| ListCollections(ctx, limit, offset)      | POST /api/collections.list                 | {"limit","offset"}                                 | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count |
| ListDocuments(ctx, req)                  | POST /api/documents.list                   | {"collectionId","parentDocumentId","limit","offset"} (all optional) | L data[] Document; pagination; top-level total |
| CreateDocument(ctx, req)                 | POST /api/documents.create                 | {"title"!,"collectionId"!,"text","parentDocumentId","publish"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) |
| UpdateDocument(ctx, req)                 | POST /api/documents.update                 | {"id"!(uuid|urlId),"title","text","append","publish"} | S (v1 spec; same Document envelope) |
| CreateComment(ctx, req)                  | POST /api/comments.create                  | {"entityType":"document","entityId"!,"text"!,"parentCommentId"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment |
| ListComments(ctx, req)                   | POST /api/comments.list                    | {"entityId"!,"isResolved"(*bool),"threadId","limit"≤100,"offset"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total |
| ResolveComment(ctx, id, resolved)        | POST /api/comments.resolve                 | {"id"!,"isResolved"!}                              | S (v2-preview spec) data.comment |
| ParseDocRefs(baseURL, text) []string     | (pure, no HTTP)                            | —                                                  | ordered dedup'd doc-id tokens from free text |

Deliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC
fileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list
{documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302
signed URL — both spec'd, redirect flow live-verified via fileOperations), events.list +
webhookSubscriptions.* (Q&A bead extends the client then), bots CRUD (one-time provisioning, curl
runbook below), documents.delete.

## Types

Document{ID, URLID, Title, URL (site-relative "/doc/<slug>-<urlId>"), CollectionID string,
ParentDocumentID *string, Text string, Type string ("document"|"database"|"row"|"whiteboard"|"embed"),
Revision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless),
CreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string,
Sharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string,
IsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string,
IsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}.

ParseDocRefs(baseURL, text): regexp `https?://<escaped-host>/doc/([A-Za-z0-9._~-]+)` where host
comes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info
accepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both
raw markdown and TipTap HTML (`<a href="...">`); dedup preserving first occurrence.

## Errors

`*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error
envelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body
read capped 8MiB (mem0 constants). Observed error bodies (assert in tests):
- 401 authentication_required, message "Unable to decode JWT token" (bad token) / "Authentication or shareId required" (no token)
- 404 not_found, "Resource not found" (documents.info, unknown uuid AND unknown urlId) / "Document not found" (v2 markdown)
- 400 invalid_format, "Invalid UUID (id)" — NOTE: this body has NO "status" key
- 500 internal_server_error (markdown export of a whiteboard doc)

## Quirks for doc.go (all verified live unless noted)

1. Identity: doc URL is `https://<team>.yonote.ru/doc/<slug>-<urlId>`; documents.info resolves
   uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only.
2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all
   markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them).
   /v2/.../markdown is canonical BUT returned "" for an API-created never-edited draft whose .text
   still held the submitted markdown verbatim, and returns "" for database-type docs. Whether the
   draft case is "draft" or "no editor state yet" is UNVERIFIED — callers use the fallback chain
   (markdown → info.Text → unavailable) which covers both.
3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and
   returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via
   fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client.
4. type=whiteboard → markdown export 500s; type=database → "" (v2 search/accessible spec says
   types document|database|row|whiteboard|embed).
5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the
   top level, /api/v2/* puts total inside pagination. Don't share one envelope type.
6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it,
   mem0 precedent); surface 429 as *APIError if it ever appears.
7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists
   ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/
   developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1);
   yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt.
8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP
   endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this
   client but explains the product's own tool surface).

## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads)

The daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast
radius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots
returns [] — none exist yet.

1. POST /api/v2/bots               {"name":"agent-hub","username":"agent-hub"}   → data: bot user (grab id; user has isBot:true)
2. POST /api/v2/bots/<botId>/token {"name":"agenthubd"}                          → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env
3. Grant collections (bots see nothing by default per Bots tag doc "granted access to collections
   with read or write permissions"): POST /api/collections.add_user {"id":<collectionId>,
   "userId":<botId>,"permission":"read"|"read_write"} (v1-spec'd, NOT live-verified).
4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then
   documents.info on a granted doc, CreateDocument in the write collection.
   UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights.
Rotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}.

Daemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in
prod must be visible, not fatal).

## Test plan (httptest, mem0-test style)

Per method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into
typed result. Error mapping: 401/404 envelopes, 400 body WITHOUT "status" key, non-JSON body →
Snippet only. Nested data.comment / data.comments decode. ExportMarkdown "" passthrough (no error).
DocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table:
bare URL in markdown, TipTap `<a href="https://host/doc/x-abc">`, uuid-in-URL, trailing
punctuation/")"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is
bead-2 policy).
Notes
Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both).

Depends on

No outgoing dependencies.

Depended on by

  • ah-25e — Yonote publish lane: .task/publish.json → bot-authored docs at finalize blocks
  • ah-ptu — Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5) blocks
  • ah-2lh — Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest blocks

Unblocks — everything waiting on this, transitively

  • ah-25e — Yonote publish lane: .task/publish.json → bot-authored docs at finalize blocks closed
  • ah-ptu — Yonote Q&A bot loop: poll doc comments, answer via board tasks (post-wave-2.5) blocks closed
  • ah-eje — Q&A loop rollout: live-verify events.list ordering + comments.resolve, wire qa role/config on agent-1 blocks closed
  • ah-2lh — Claim-time Yonote artifact materialization into .task/artifacts/ + prompt manifest blocks closed

No comments.

Close reason

Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
  • Eugene Blikh created the issue · 2026-07-18T18:03:36Z
  • Eugene Blikh ah-2lh now depends on this · 2026-07-18T18:04:55Z
  • Eugene Blikh ah-25e now depends on this · 2026-07-18T18:04:56Z
  • Eugene Blikh ah-ptu now depends on this · 2026-07-18T18:04:57Z
  • Eugene Blikh claimed · 2026-07-18T18:06:56Z
  • Eugene Blikh closed the issue · 2026-07-18T18:59:07Z
    Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
  • Eugene Blikh added label milestone:yonote · 2026-07-20T03:08:53Z
  • Eugene Blikh removed label milestone:yonote · 2026-07-20T03:10:31Z
Stored rows — what this pane was built from, as read
issues 1 row
id ah-gxa
content_hash dffb086c7b7fb989cce7ee0c79a50575efa7059b98fd57b9c4d41e23de6555fb
title internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth)
description Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q&A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges.
design # internal/yonote — light API client (stdlib-only, mem0 style) Mirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client` (trailing "/" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods, one `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go pinning the contract + quirks, httptest unit tests. No third-party deps. Contract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18. Yonote = Outline fork; API is RPC-style `POST /api/<resource>.<verb>` (JSON body) PLUS a newer `/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer <token>` (tokens are JWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`. Error envelope `{ok:false, error:"<machine_code>", status:<int, SOMETIMES ABSENT>, message:"<human>"}` — key off the HTTP status, carry error/message. ## Endpoint table (methods to implement; L = live-verified, S = spec-only) | Method | HTTP + path | Request | Response essentials | |------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------| | AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} | | DocumentInfo(ctx, id) | POST /api/documents.info | {"id"} — uuid OR urlId OR slug-urlId (all L) | L data = Document | | ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {"data":"<markdown string>"} — may be "" (see quirks) | | SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {"query","limit"} | L data[] of {context string, ranking float64, document Document}; top-level count | | ListCollections(ctx, limit, offset) | POST /api/collections.list | {"limit","offset"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count | | ListDocuments(ctx, req) | POST /api/documents.list | {"collectionId","parentDocumentId","limit","offset"} (all optional) | L data[] Document; pagination; top-level total | | CreateDocument(ctx, req) | POST /api/documents.create | {"title"!,"collectionId"!,"text","parentDocumentId","publish"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) | | UpdateDocument(ctx, req) | POST /api/documents.update | {"id"!(uuid|urlId),"title","text","append","publish"} | S (v1 spec; same Document envelope) | | CreateComment(ctx, req) | POST /api/comments.create | {"entityType":"document","entityId"!,"text"!,"parentCommentId"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment | | ListComments(ctx, req) | POST /api/comments.list | {"entityId"!,"isResolved"(*bool),"threadId","limit"≤100,"offset"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total | | ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {"id"!,"isResolved"!} | S (v2-preview spec) data.comment | | ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text | Deliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC fileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list {documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302 signed URL — both spec'd, redirect flow live-verified via fileOperations), events.list + webhookSubscriptions.* (Q&A bead extends the client then), bots CRUD (one-time provisioning, curl runbook below), documents.delete. ## Types Document{ID, URLID, Title, URL (site-relative "/doc/<slug>-<urlId>"), CollectionID string, ParentDocumentID *string, Text string, Type string ("document"|"database"|"row"|"whiteboard"|"embed"), Revision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless), CreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string, Sharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string, IsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string, IsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}. ParseDocRefs(baseURL, text): regexp `https?://<escaped-host>/doc/([A-Za-z0-9._~-]+)` where host comes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info accepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both raw markdown and TipTap HTML (`<a href="...">`); dedup preserving first occurrence. ## Errors `*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error envelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body read capped 8MiB (mem0 constants). Observed error bodies (assert in tests): - 401 authentication_required, message "Unable to decode JWT token" (bad token) / "Authentication or shareId required" (no token) - 404 not_found, "Resource not found" (documents.info, unknown uuid AND unknown urlId) / "Document not found" (v2 markdown) - 400 invalid_format, "Invalid UUID (id)" — NOTE: this body has NO "status" key - 500 internal_server_error (markdown export of a whiteboard doc) ## Quirks for doc.go (all verified live unless noted) 1. Identity: doc URL is `https://<team>.yonote.ru/doc/<slug>-<urlId>`; documents.info resolves uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only. 2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them). /v2/.../markdown is canonical BUT returned "" for an API-created never-edited draft whose .text still held the submitted markdown verbatim, and returns "" for database-type docs. Whether the draft case is "draft" or "no editor state yet" is UNVERIFIED — callers use the fallback chain (markdown → info.Text → unavailable) which covers both. 3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client. 4. type=whiteboard → markdown export 500s; type=database → "" (v2 search/accessible spec says types document|database|row|whiteboard|embed). 5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the top level, /api/v2/* puts total inside pagination. Don't share one envelope type. 6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it, mem0 precedent); surface 429 as *APIError if it ever appears. 7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/ developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1); yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt. 8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this client but explains the product's own tool surface). ## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads) The daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast radius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots returns [] — none exist yet. 1. POST /api/v2/bots {"name":"agent-hub","username":"agent-hub"} → data: bot user (grab id; user has isBot:true) 2. POST /api/v2/bots/<botId>/token {"name":"agenthubd"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env 3. Grant collections (bots see nothing by default per Bots tag doc "granted access to collections with read or write permissions"): POST /api/collections.add_user {"id":<collectionId>, "userId":<botId>,"permission":"read"|"read_write"} (v1-spec'd, NOT live-verified). 4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then documents.info on a granted doc, CreateDocument in the write collection. UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights. Rotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}. Daemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in prod must be visible, not fatal). ## Test plan (httptest, mem0-test style) Per method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into typed result. Error mapping: 401/404 envelopes, 400 body WITHOUT "status" key, non-JSON body → Snippet only. Nested data.comment / data.comments decode. ExportMarkdown "" passthrough (no error). DocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table: bare URL in markdown, TipTap `<a href="https://host/doc/x-abc">`, uuid-in-URL, trailing punctuation/")"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is bead-2 policy).
acceptance_criteria
notes Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both).
status closed
priority 2
issue_type feature
assignee Eugene Blikh
estimated_minutes NULL
created_at 2026-07-18T15:03:36Z
created_by Eugene Blikh
owner bigbes@gmail.com
updated_at 2026-07-18T15:59:07Z
closed_at 2026-07-18T15:59:07Z
closed_by_session
external_ref NULL
spec_id
compaction_level 0
compacted_at NULL
compacted_at_commit NULL
original_size NULL
sender
ephemeral 0
wisp_type
pinned 0
is_template 0
mol_type
work_type
source_system
metadata �{}
source_repo
close_reason Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
event_kind
actor
target
payload
await_type
await_id
timeout_ns 0
waiters
hook_bead
role_bead
agent_state
last_activity NULL
role_type
rig
due_at NULL
defer_until NULL
no_history 0
started_at 2026-07-18T15:06:56Z
is_blocked 0
dependencies 3 rows
id 5b6045e4-6bd4-5f00-bb39-d36a8c263ace
issue_id ah-25e
type blocks
created_at 2026-07-18T18:04:56Z
created_by Eugene Blikh
metadata �{}
thread_id
depends_on_issue_id ah-gxa
depends_on_wisp_id NULL
depends_on_external NULL
id 6a912c84-0b5c-5b5f-9646-941067cace98
issue_id ah-ptu
type blocks
created_at 2026-07-18T18:04:57Z
created_by Eugene Blikh
metadata �{}
thread_id
depends_on_issue_id ah-gxa
depends_on_wisp_id NULL
depends_on_external NULL
id c216a252-8df4-59d4-b191-6d2ab71ef4f5
issue_id ah-2lh
type blocks
created_at 2026-07-18T18:04:55Z
created_by Eugene Blikh
metadata �{}
thread_id
depends_on_issue_id ah-gxa
depends_on_wisp_id NULL
depends_on_external NULL
events 5 rows
id 019f75c1-169f-7b9c-a3eb-441bc5115cb0
issue_id ah-gxa
event_type created
actor Eugene Blikh
old_value
new_value
comment NULL
created_at 2026-07-18T18:03:36Z
id 019f75c4-23da-727c-ac89-db89963919f6
issue_id ah-gxa
event_type claimed
actor Eugene Blikh
old_value {"id":"ah-gxa","title":"internal/yonote: light API client (doc resolve/export, create, comments; bot-token auth)","description":"Why: agenthubd integrates Yonote (Outline-fork docs product, live at bigbes.yonote.ru) in three slices — claim-time artifact export, finalize-time publishing, later Q\u0026A. All need one light client. What: internal/yonote in the internal/mem0 style (New(baseURL, token, opts), ctx-first, typed *APIError, stdlib-only, httptest tests, doc.go pinning contract+quirks), scoped to exactly the integration surface: AuthInfo, DocumentInfo (uuid|urlId|slug-urlId), ExportMarkdown (/api/v2/documents/{uuid}/markdown), Search/List, CreateDocument/UpdateDocument, comments create/list/resolve, pure ParseDocRefs URL helper. Runs under a BOT token (minting runbook in design). wave 2.5 — implement after feat/wave2-archive-links merges.","design":"# internal/yonote — light API client (stdlib-only, mem0 style)\n\nMirror internal/mem0 exactly in shape: `New(baseURL, token string, opts ...Option) *Client`\n(trailing \"/\" trimmed, default `http.Client{Timeout: 30s}`, `WithHTTPClient`), ctx-first methods,\none `do(ctx, method, path, query, in, out)` round-tripper, typed `*APIError`, package doc.go\npinning the contract + quirks, httptest unit tests. No third-party deps.\n\nContract pinned to live bigbes.yonote.ru, x-app-version 1.47.1, verified 2026-07-18.\nYonote = Outline fork; API is RPC-style `POST /api/\u003cresource\u003e.\u003cverb\u003e` (JSON body) PLUS a newer\n`/api/v2/*` REST namespace. Auth on every request: `Authorization: Bearer \u003ctoken\u003e` (tokens are\nJWTs; treat as opaque). Success envelope `{data, status, ok, [pagination, count|total, policies]}`.\nError envelope `{ok:false, error:\"\u003cmachine_code\u003e\", status:\u003cint, SOMETIMES ABSENT\u003e, message:\"\u003chuman\u003e\"}`\n— key off the HTTP status, carry error/message.\n\n## Endpoint table (methods to implement; L = live-verified, S = spec-only)\n\n| Method | HTTP + path | Request | Response essentials |\n|------------------------------------------|--------------------------------------------|----------------------------------------------------|---------------------|\n| AuthInfo(ctx) | POST /api/auth.info | {} | L data.user{id,name,email,isBot,isAdmin,isViewer}, data.team{name,url,subdomain} |\n| DocumentInfo(ctx, id) | POST /api/documents.info | {\"id\"} — uuid OR urlId OR slug-urlId (all L) | L data = Document |\n| ExportMarkdown(ctx, uuid) | GET /api/v2/documents/{uuid}/markdown | path uuid ONLY (urlId → 400 invalid_format, L) | L {\"data\":\"\u003cmarkdown string\u003e\"} — may be \"\" (see quirks) |\n| SearchDocuments(ctx, req{Query,Limit}) | POST /api/documents.search | {\"query\",\"limit\"} | L data[] of {context string, ranking float64, document Document}; top-level count |\n| ListCollections(ctx, limit, offset) | POST /api/collections.list | {\"limit\",\"offset\"} | L data[] Collection{ID,Name,URLID,URL,Permission,Sharing}; pagination{limit,offset,nextPath}; top-level count |\n| ListDocuments(ctx, req) | POST /api/documents.list | {\"collectionId\",\"parentDocumentId\",\"limit\",\"offset\"} (all optional) | L data[] Document; pagination; top-level total |\n| CreateDocument(ctx, req) | POST /api/documents.create | {\"title\"!,\"collectionId\"!,\"text\",\"parentDocumentId\",\"publish\"} | L data = Document (text echoed VERBATIM markdown; publish:false ⇒ publishedAt null even with team autoPublishDocs) |\n| UpdateDocument(ctx, req) | POST /api/documents.update | {\"id\"!(uuid|urlId),\"title\",\"text\",\"append\",\"publish\"} | S (v1 spec; same Document envelope) |\n| CreateComment(ctx, req) | POST /api/comments.create | {\"entityType\":\"document\",\"entityId\"!,\"text\"!,\"parentCommentId\"} | L data.comment{id,entityId,threadId,text,quote,isResolved,createdById,createdAt,updatedAt} — NESTED under data.comment |\n| ListComments(ctx, req) | POST /api/comments.list | {\"entityId\"!,\"isResolved\"(*bool),\"threadId\",\"limit\"≤100,\"offset\"} | L data.comments[] (nested; items also carry threadCommentsCount, attachments[]); pagination; total |\n| ResolveComment(ctx, id, resolved) | POST /api/comments.resolve | {\"id\"!,\"isResolved\"!} | S (v2-preview spec) data.comment |\n| ParseDocRefs(baseURL, text) []string | (pure, no HTTP) | — | ordered dedup'd doc-id tokens from free text |\n\nDeliberately OUT of the client (documented in doc.go): documents.export RPC (now an ASYNC\nfileOperation on live — see quirks), attachments.* (follow-up if ever needed: attachments.list\n{documentId} → items carry redirectUrl/url/contentType/size/name; attachments.redirect {id} → 302\nsigned URL — both spec'd, redirect flow live-verified via fileOperations), events.list +\nwebhookSubscriptions.* (Q\u0026A bead extends the client then), bots CRUD (one-time provisioning, curl\nrunbook below), documents.delete.\n\n## Types\n\nDocument{ID, URLID, Title, URL (site-relative \"/doc/\u003cslug\u003e-\u003curlId\u003e\"), CollectionID string,\nParentDocumentID *string, Text string, Type string (\"document\"|\"database\"|\"row\"|\"whiteboard\"|\"embed\"),\nRevision int, ArchivedAt, DeletedAt, PublishedAt *string (keep RFC3339 strings, lossless),\nCreatedAt, UpdatedAt string, ChildrenCount int}. Collection{ID, URLID, Name, URL, Permission string,\nSharing bool}. Comment{ID, EntityID string, ThreadID *string, Text string, Quote *string,\nIsResolved bool, CreatedByID, CreatedAt, UpdatedAt string}. AuthInfo{User{ID, Name, Email string,\nIsBot, IsAdmin, IsViewer bool}, Team{Name, URL, Subdomain string}}.\n\nParseDocRefs(baseURL, text): regexp `https?://\u003cescaped-host\u003e/doc/([A-Za-z0-9._~-]+)` where host\ncomes from url.Parse(baseURL). Token = full last path segment (slug-urlId form — documents.info\naccepts it whole, no urlId slicing needed); query/#fragment excluded by the charset; works on both\nraw markdown and TipTap HTML (`\u003ca href=\"...\"\u003e`); dedup preserving first occurrence.\n\n## Errors\n\n`*APIError{StatusCode int, Method, Path, Code, Message, Snippet string}` parsed from the error\nenvelope; `(e *APIError) NotFound() bool` (StatusCode==404). Body snippet capped 2KiB; success-body\nread capped 8MiB (mem0 constants). Observed error bodies (assert in tests):\n- 401 authentication_required, message \"Unable to decode JWT token\" (bad token) / \"Authentication or shareId required\" (no token)\n- 404 not_found, \"Resource not found\" (documents.info, unknown uuid AND unknown urlId) / \"Document not found\" (v2 markdown)\n- 400 invalid_format, \"Invalid UUID (id)\" — NOTE: this body has NO \"status\" key\n- 500 internal_server_error (markdown export of a whiteboard doc)\n\n## Quirks for doc.go (all verified live unless noted)\n\n1. Identity: doc URL is `https://\u003cteam\u003e.yonote.ru/doc/\u003cslug\u003e-\u003curlId\u003e`; documents.info resolves\n uuid | bare urlId | slug-urlId; ALL /api/v2/* paths take the uuid only.\n2. Document.Text is NOT reliable markdown: after editor edits it is a PLAINTEXT projection (all\n markup stripped — verified on a real doc: text had no #/**/`` while /markdown had them).\n /v2/.../markdown is canonical BUT returned \"\" for an API-created never-edited draft whose .text\n still held the submitted markdown verbatim, and returns \"\" for database-type docs. Whether the\n draft case is \"draft\" or \"no editor state yet\" is UNVERIFIED — callers use the fallback chain\n (markdown → info.Text → unavailable) which covers both.\n3. documents.export RPC diverges from Outline AND from the v1 docs: live it requires a uuid and\n returns an async fileOperation {state waiting→complete} (poll fileOperations.info, download via\n fileOperations.redirect → 302 signed Yandex-cloud URL). Not wrapped by this client.\n4. type=whiteboard → markdown export 500s; type=database → \"\" (v2 search/accessible spec says\n types document|database|row|whiteboard|embed).\n5. Pagination: {limit, offset, nextPath}; totals are INCONSISTENT — v1 RPC puts count/total at the\n top level, /api/v2/* puts total inside pagination. Don't share one envelope type.\n6. No rate-limit headers at 1.47.1; no documented limits. No client-side retry (callers own it,\n mem0 precedent); surface 429 as *APIError if it ever appears.\n7. Live (1.47.1) is newer than the published docs (1.38.1). The v2-preview OpenAPI spec exists\n ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next/static/chunks/pages/\n developers-*.js, two JSON.parse('...') literals: v2-preview then legacy v1);\n yonote.ru/openapi-3.json serves the OLD v1 spec. Re-extract from the chunk when in doubt.\n8. Team feature flags seen in auth.info: mcpServer:true + mcpSettings (Yonote hosts a native MCP\n endpoint at /api/v2/mcp — the operator's claude.ai integration uses it; irrelevant to this\n client but explains the product's own tool surface).\n\n## Bot-token provisioning runbook (one-time, operator; do BEFORE deploying the integration beads)\n\nThe daemon must authenticate as a BOT, not as bigbes's personal token (attribution + blast\nradius). Bots are Yonote-native (not in Outline). Admin-only. As of 2026-07-18 GET /api/v2/bots\nreturns [] — none exist yet.\n\n1. POST /api/v2/bots {\"name\":\"agent-hub\",\"username\":\"agent-hub\"} → data: bot user (grab id; user has isBot:true)\n2. POST /api/v2/bots/\u003cbotId\u003e/token {\"name\":\"agenthubd\"} → data{id,name,secret,...} — `secret` IS the API token, shown ONCE → store as YONOTE_TOKEN in /etc/agent-hub/env\n3. Grant collections (bots see nothing by default per Bots tag doc \"granted access to collections\n with read or write permissions\"): POST /api/collections.add_user {\"id\":\u003ccollectionId\u003e,\n \"userId\":\u003cbotId\u003e,\"permission\":\"read\"|\"read_write\"} (v1-spec'd, NOT live-verified).\n4. Verify with the minted token: POST /api/auth.info → user.isBot must be true; then\n documents.info on a granted doc, CreateDocument in the write collection.\n UNVERIFIED until then: auth.info under a bot token, bot default visibility, bot create rights.\nRotation: GET /api/v2/bots/{id}/token (list, no secrets), DELETE /api/v2/bots/{id}/token/{tokenId}.\n\nDaemon behavior: on startup call AuthInfo, log identity, WARN when IsBot==false (personal token in\nprod must be visible, not fatal).\n\n## Test plan (httptest, mem0-test style)\n\nPer method: happy path asserting HTTP method, exact path, Bearer header, JSON body; decode into\ntyped result. Error mapping: 401/404 envelopes, 400 body WITHOUT \"status\" key, non-JSON body →\nSnippet only. Nested data.comment / data.comments decode. ExportMarkdown \"\" passthrough (no error).\nDocumentInfo with the three id forms (same handler, three paths asserted). ParseDocRefs table:\nbare URL in markdown, TipTap `\u003ca href=\"https://host/doc/x-abc\"\u003e`, uuid-in-URL, trailing\npunctuation/\")\"/query/fragment, cross-host rejected, http vs https, dedup order, cap-free (cap is\nbead-2 policy).\n","notes":"Live-verified 2026-07-18 against bigbes.yonote.ru x-app-version 1.47.1 (docs page documents 1.38.1 — live is newer). Evidence: (1) auth.info via Bearer OK — the operator token in use today is PERSONAL (user.isBot=false, isAdmin=true); no bots exist yet (GET /api/v2/bots → []); mint one before deploy (runbook in design). (2) documents.info resolved the same doc by uuid, bare urlId, slug-urlId. (3) /api/v2/documents/{uuid}/markdown returned faithful markdown for an editor-edited doc while documents.info .text for the SAME doc was plaintext with all markup stripped — .text is NOT markdown after editor edits. (4) One-probe-doc lifecycle (created→deleted, approved): documents.create with markdown text echoed text VERBATIM and honored publish:false (publishedAt null despite team autoPublishDocs:true); /markdown for that fresh API-created draft returned '' — hence the markdown→.text fallback chain; comments.create → {data:{comment}} nested, text stored verbatim, comments.list → {data:{comments:[…]}}; documents.delete {permanent:true} → {success:true}; info after delete → 404. (5) documents.export RPC live = ASYNC fileOperation (uuid-only, 400 invalid_format on urlId); fileOperations.info reached state complete and fileOperations.redirect 302'd to a signed Yandex-storage URL (Camera-export.md). SIDE EFFECT LEFT BEHIND: one accidental export fileOperation record (id 6b6e2056-9487-4f91-b10b-818d7c1a486c, doc 'Camera', 604B, complete) lingers in the workspace export history — harmless, operator may delete. (6) Error bodies: 401 authentication_required 'Unable to decode JWT token' / 'Authentication or shareId required'; 404 not_found; 400 invalid_format WITHOUT a status key; 500 internal_server_error on whiteboard markdown export; database-type docs export ''. (7) No rate-limit headers observed. (8) v2-preview OpenAPI spec exists ONLY embedded in the docs-page JS (yonote.ru/developers?v=2 → _next chunk pages/developers-*.js, two JSON.parse single-quoted literals: v2-preview 1.38.1 with /v2/bots + /v2/documents, then legacy v1 0.1.0); yonote.ru/openapi-3.json serves the OLD v1 spec. (9) webhookSubscriptions.list responds live (empty) though undocumented in both specs; events.list live-verified. UNVERIFIED (implementer/provisioning): auth.info under a bot token; bot default visibility + collections.add_user grant; bot create rights; documents.update; comments.resolve; whether /markdown '' means draft-only or missing-editor-state (fallback chain covers both).","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-18T15:03:36Z","created_by":"Eugene Blikh","updated_at":"2026-07-18T15:03:36Z"}
new_value {"assignee":"Eugene Blikh","status":"in_progress"}
comment NULL
created_at 2026-07-18T18:06:56Z
id 019f75f3-ea08-74a7-8e20-cde907d25363
issue_id ah-gxa
event_type closed
actor Eugene Blikh
old_value
new_value Merged to master f9a7b4b (3 commits 6c9e259/a25b692/f9a7b4b): yonote client + claim-time artifact materialization + publish lane. Live smoke at rollout per ah-25e notes.
comment NULL
created_at 2026-07-18T18:59:07Z
id 019f7cda-aa89-74f1-acc1-890e9f5bfe77
issue_id ah-gxa
event_type label_added
actor Eugene Blikh
old_value NULL
new_value NULL
comment Added label: milestone:yonote
created_at 2026-07-20T03:08:53Z
id 019f7cdc-28e1-7bea-941c-8dfb787b36ac
issue_id ah-gxa
event_type label_removed
actor Eugene Blikh
old_value NULL
new_value NULL
comment Removed label: milestone:yonote
created_at 2026-07-20T03:10:31Z