openapi: "3.1.0"
info:
title: Twofold
version: "0.4.0"
description: |
One document, two views. Twofold is a self-hosted markdown share service that serves
styled HTML to humans and raw markdown to agents from the same document.
## Authentication
Write operations (create, update, delete, list) require a Bearer token in the
Authorization header. Read operations on the human-facing routes are public unless
the document is password-protected.
```
Authorization: Bearer <token>
```
## Frontmatter
Documents may include a YAML frontmatter block at the start:
```markdown
---
title: My Document
slug: custom-slug
theme: dark
expiry: 7d
password: secret
description: A short summary
---
# Document body
```
Frontmatter fields:
| Field | Type | Description |
|-------------|--------|----------------------------------------------------------|
| title | string | Document title (overrides H1 extraction) |
| slug | string | Custom URL slug (alphanumeric + hyphen, 3-128 chars) |
| theme | string | Rendering theme: `clean`, `dark`, `paper`, `minimal` |
| expiry | string | Expiry duration: `30m`, `24h`, `7d`, `2w` (min 5m) |
| password | string | Password-protect the human view |
| description | string | Short description (returned in API responses) |
contact:
name: Geoff Baum
url: https://github.com/gabaum10/twofold
servers:
- url: http://localhost:3000
description: Local development server
security:
- BearerAuth: []
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: |
Admin token (TWOFOLD_TOKEN env var) or a managed token created via
`twofold token create`. All tokens have full access.
schemas:
DocumentCreated:
type: object
required: [url, slug, api_url, title, created_at]
properties:
url:
type: string
format: uri
description: Human-facing URL for the document.
example: "http://localhost:3000/board-q1"
slug:
type: string
description: URL slug (auto-generated or custom).
example: "board-q1"
api_url:
type: string
format: uri
description: Agent API URL for raw markdown retrieval.
example: "http://localhost:3000/api/v1/documents/board-q1"
title:
type: string
description: Document title (frontmatter > H1 > slug).
example: "Board Report Q1"
description:
type: string
nullable: true
description: Short description from frontmatter.
example: "Q1 summary for the board"
created_at:
type: string
format: date-time
description: ISO 8601 UTC creation timestamp.
example: "2026-05-10T03:22:00Z"
expires_at:
type: string
format: date-time
nullable: true
description: ISO 8601 UTC expiry timestamp. Null if no expiry.
example: "2026-05-17T03:22:00Z"
DocumentSummary:
type: object
required: [slug, title, created_at]
properties:
slug:
type: string
example: "board-q1"
title:
type: string
example: "Board Report Q1"
description:
type: string
nullable: true
example: "Q1 summary for the board"
created_at:
type: string
format: date-time
example: "2026-05-10T03:22:00Z"
expires_at:
type: string
format: date-time
nullable: true
example: null
DocumentList:
type: object
required: [documents, total, limit, offset]
properties:
documents:
type: array
items:
$ref: "#/components/schemas/DocumentSummary"
total:
type: integer
description: Total count of non-expired documents (for pagination).
example: 42
limit:
type: integer
description: The limit applied to this response.
example: 20
offset:
type: integer
description: The offset applied to this response.
example: 0
Error:
type: object
required: [error]
properties:
error:
type: string
description: Human-readable error message.
example: "Not found"
paths:
/api/v1/documents:
post:
summary: Create a document
operationId: createDocument
description: |
Publish a new markdown document. The request body is raw markdown text
(Content-Type: text/markdown). Optional YAML frontmatter at the top of the
body controls title, slug, theme, expiry, password, and description.
Returns 409 Conflict if a custom slug is already in use.
Returns 413 Payload Too Large if the body exceeds TWOFOLD_MAX_SIZE (default 1MB).
security:
- BearerAuth: []
requestBody:
required: true
content:
text/markdown:
schema:
type: string
description: |
Raw markdown content, optionally preceded by YAML frontmatter.
example: |
---
title: Board Report Q1
slug: board-q1
theme: clean
expiry: 7d
---
# Board Report Q1
Revenue up 12% quarter-over-quarter.
responses:
"201":
description: Document created successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentCreated"
"400":
description: |
Bad request. Possible causes:
- Empty request body
- Invalid UTF-8 in body
- Invalid frontmatter YAML
- Invalid slug format (reserved, too short, invalid chars, starts/ends with hyphen)
- Invalid expiry format or out of range
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: Custom slug already in use.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"413":
description: Request body exceeds the configured maximum size.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
get:
summary: List documents
operationId: listDocuments
description: |
Returns a paginated list of non-expired document summaries, ordered by
creation date descending (newest first).
Does NOT return raw document content — metadata only.
Expired documents are excluded from results and from the total count.
security:
- BearerAuth: []
parameters:
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Maximum number of results to return. Capped at 100.
- name: offset
in: query
schema:
type: integer
minimum: 0
default: 0
description: Number of results to skip. Values below 0 are treated as 0.
responses:
"200":
description: Document list returned successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentList"
example:
documents:
- slug: "board-q1"
title: "Board Report Q1"
description: "Q1 summary"
created_at: "2026-05-10T03:22:00Z"
expires_at: null
total: 1
limit: 20
offset: 0
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/v1/documents/{slug}:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Document slug (alphanumeric + hyphen).
example: "board-q1"
get:
summary: Get document (agent view)
operationId: getDocument
description: |
Returns the full raw markdown source exactly as it was POSTed,
including any frontmatter and `<!-- @agent -->` sections.
This endpoint is NOT password-gated — it is intended for agent access.
Password-protected documents are readable here with a valid bearer token.
Returns 410 Gone if the document has expired.
security:
- BearerAuth: []
responses:
"200":
description: Raw markdown content.
content:
text/markdown:
schema:
type: string
example: |
---
title: Board Report Q1
---
# Board Report Q1
Revenue up 12%.
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"410":
description: Document has expired and been deleted.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
put:
summary: Update a document
operationId: updateDocument
description: |
Replace the content of an existing document. The slug in the URL is
authoritative — any slug field in frontmatter is ignored on PUT.
Expiry, theme, and password can be changed on update. To remove an expiry,
omit the `expiry` frontmatter field. To remove a password, omit the
`password` frontmatter field (or set it to an empty string).
Returns 410 Gone if the document has expired.
security:
- BearerAuth: []
requestBody:
required: true
content:
text/markdown:
schema:
type: string
description: New markdown content for the document.
responses:
"200":
description: Document updated successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/DocumentCreated"
"400":
description: Bad request (empty body, invalid frontmatter, invalid expiry).
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"410":
description: Document has expired.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
summary: Delete a document
operationId: deleteDocument
description: |
Permanently delete a document by slug. Returns 204 on success.
Expired documents can still be explicitly deleted (cleanup use case).
Returns 404 if the document does not exist.
security:
- BearerAuth: []
responses:
"204":
description: Document deleted successfully. No response body.
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/v1/openapi.yaml:
get:
summary: OpenAPI spec (YAML)
operationId: getOpenApiYaml
description: |
Returns this OpenAPI specification as YAML (OpenAPI 3.1.0).
Public — no authentication required.
security: []
responses:
"200":
description: OpenAPI 3.1.0 specification in YAML format.
content:
application/yaml:
schema:
type: string
/api/v1/openapi.json:
get:
summary: OpenAPI spec (JSON)
operationId: getOpenApiJson
description: |
Returns the OpenAPI specification as JSON (derived from the canonical YAML).
Some tools prefer JSON over YAML. Public — no authentication required.
security: []
responses:
"200":
description: OpenAPI 3.1.0 specification in JSON format.
content:
application/json:
schema:
type: object
/{slug}:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Document slug.
example: "board-q1"
- name: raw
in: query
schema:
type: string
enum: ["1"]
description: |
When set to `1`, returns the raw markdown source instead of rendered HTML.
Subject to the same password gate as the human view.
get:
summary: Human view (themed HTML or raw markdown)
operationId: getHumanView
description: |
The primary human-facing endpoint. Returns a fully rendered, styled HTML page.
- `<!-- @agent -->` / `<!-- @end -->` sections are stripped from the human view.
- Frontmatter is stripped from the rendered output.
- Code blocks are syntax-highlighted.
- If the document is password-protected and the user has not authenticated,
returns the password prompt page (200, not 401).
With `?raw=1`: returns the full raw markdown source (password-gated).
Returns 410 Gone if the document has expired.
security: []
responses:
"200":
description: |
Rendered HTML page (or raw markdown if ?raw=1, or password prompt if protected).
content:
text/html:
schema:
type: string
text/markdown:
schema:
type: string
"302":
description: Internal redirect (not exposed externally).
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"410":
description: Document has expired.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/{slug}/full:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Document slug.
get:
summary: Full rendered view (agent sections included)
operationId: getFullView
description: |
Renders the full document including content from `<!-- @agent -->` sections.
The marker comment lines themselves are stripped, but their content is kept.
`<!-- @instructions -->` / `<!-- @end-instructions -->` blocks are removed
entirely (both markers and content).
Password-protected documents still require authentication via cookie.
Returns 410 Gone if the document has expired.
security: []
responses:
"200":
description: Full rendered HTML page.
content:
text/html:
schema:
type: string
"302":
description: Password gate redirect (not exposed externally).
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"410":
description: Document has expired.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/{slug}/unlock:
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: Document slug.
post:
summary: Unlock a password-protected document
operationId: unlockDocument
description: |
Verifies the password for a protected document. On success, sets an
HttpOnly HMAC-signed cookie and redirects (303) to the document view.
On failure, returns the password prompt page again with an error message.
The auth cookie is valid for 1 hour.
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [password]
properties:
password:
type: string
description: The document password.
responses:
"303":
description: Password correct. Redirects to the document view with auth cookie set.
headers:
Location:
schema:
type: string
description: URL of the document.
Set-Cookie:
schema:
type: string
description: HttpOnly HMAC-signed auth cookie, valid for 1 hour.
"200":
description: |
Password incorrect. Returns password prompt page with error message.
(Not a 4xx — the form is re-displayed.)
content:
text/html:
schema:
type: string
"404":
description: Document not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"410":
description: Document has expired.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"