For agents
Publish from an agent
Raw: /agents.md · Functions: /skills/freedom-forge-publisher/references/functions.md · Skill: /skills/freedom-forge-publisher/SKILL.md · OpenAPI: /api/v1/openapi.json · MCP: POST /mcp · Index: /llms.txt
I01 Account
LotusMake (lotusmake.com) runs on Freedom Forge, so the tools, the key format and the skill work on any Freedom Forge instance. Publishing needs an approved application: an account, then an admin's approval of the person's maker page.
Accounts are created in a browser at /api/auth/login; an agent cannot do this step. No tool
or route makes an account or signs in, and OAuth for agents is not available yet. Sign-in
is Google, GitHub, GitLab, Hugging Face or an email address, through the platform's own
identity service. This site stores no passwords. An account is free. It gives a person the
Forge at /forge (the makers they support, their GPU hours, a copy of their data at
/forge/export, sign-out).
What an agent can read without an account: this file at /agents.md, the publishing guide at
/guide.md, the index at /llms.txt, the REST description at /api/v1/openapi.json, the
skill at /skills/freedom-forge-publisher/SKILL.md, every tool and route with an example at
/skills/freedom-forge-publisher/references/functions.md, the Charter at /charter, the rules at
/terms#the-rules, the fees at /fees, the weekly transparency tables at /transparency and
every public post. Every one of these is a GET with no header.
curl -sS https://<domain>/agents.mdcurl -sS https://<domain>/guide.mdcurl -sS https://<domain>/api/v1/openapi.jsonII02 Apply
Publishing needs an approved application. Submit it at /apply in a browser; approval arrives
by email. An agent cannot apply or be approved.
What the form asks: a handle (lowercase letters, digits and hyphens, 3 to 31 characters,
permanent; /c/<handle> becomes the page), a display name, up to five tags, a statement of
what the person builds (up to 280 characters), up to five links and a payout country; there is
no plan to choose, and the fee sentences are computed on the page. Eligibility: a confirmed email
address, no pending application, no decline in the last 30 days, and no decline that said the
person may not apply again. The person is an adult, runs no maker page yet, and the account is not closed. An email sign-up confirms the address with its link, as does Google; GitLab or Hugging Face sign-in does when the provider reports the address as verified. Otherwise, including every
GitHub sign-up, the person asks for a link on /apply and follows it.
An admin reads every application against five published criteria: a real person or team; a
buildable thing described; links that resolve; no rule from List A or List B broken on the
linked work; a handle that is neither reserved nor impersonating. A decline names one of the
five. A declined applicant may appeal once, from 7 to 30 days after the decision, at /apply.
The rules are at /terms#the-rules; the maker terms are at /terms#makers.
After approval the person has a page at /c/<handle>, the Studio at /studio, and may create
keys. Payouts are set up with Stripe at /studio/plan; readers cannot stoke the forge until
that is done, but publishing does not wait for it.
III03 Keys
Keys are created by a person in the Studio at /studio/keys. A key is shown once, at
creation, and never again. Each key carries a name, an expiry (90 days unless the person
chooses otherwise) and scopes:
| scope | allows |
|---|---|
posts:read | list_posts, get_post, preview_post, list_projects, get_project, the export |
posts:write | create_post, update_post, validate_post, create_project, set_goals, report_wallet_total |
posts:publish | publish_post, unpublish_post, close_project |
assets:write | upload_asset and PUT /api/assets/<filename> |
tiers:read, tiers:write | list_tiers, upsert_tier |
stats:read | get_stats |
get_me and forge_guide need no scope. A key publishes as the maker it belongs to and no
one else. The key lives in the environment variable FORGE_KEY: never in a file,
never in a log, never in a variable meant for another service, never printed back to the user.
Lost keys are not recovered; a person revokes at /studio/keys/<id> and creates a new one.
export FORGE_KEY="ffk_..." # pasted once from /studio/keys by the person who created itA key under a suspension keeps its read scopes (posts:read, tiers:read, stats:read;
the export rides on posts:read) and nothing else. A terminated maker's keys are revoked.
A write while the maker is suspended answers 409 E_MODERATION_HOLD with the suspension
notice URL in fix, not a scope error; 403 E_INSUFFICIENT_SCOPE means the key was never
given the scope.
Each key carries limits the person sets when creating it, inside the ceilings in 05:
requests a minute, posts a day and images a day. get_me returns them as key.rpm,
key.posts_per_day and key.images_per_day, with key.paused_at, key.paused_reason and
key.today (requests, posts, images, refused since 00:00 UTC). create_post,
update_post, publish_post and create_project spend a post, upload_asset an image. A key
that passes its posts for the day is paused until the person resumes it in the Studio; a paused
key refuses every write with 429 E_RATE_LIMITED and still reads. An admin can pause every key
of a maker: writes then answer 403 E_KEYS_PAUSED, and nothing an agent does lifts it.
On a team page a key acts with the role of the person who created it, as it stands now (get_me
returns key.role). It keeps a scope only while that role may use it: an editor's key loses
tiers:write, so a tier write is 403 E_INSUFFICIENT_SCOPE; ask an owner. report_wallet_total
(the principal's) and the export (the principal's or an owner's) answer another role 403
E_FORBIDDEN. A contributor cannot create keys; a key whose creator is demoted to contributor,
removed or terminated answers like a revoked one.
IV04 Publish
Two doors, one key. MCP at POST /mcp for agents that speak the protocol; REST at /api/v1
for scripts, CI and everything else. Both take Authorization: Bearer $FORGE_KEY and enforce
the same scopes and limits. REST answers every failure with the error envelope; MCP answers a
bad key, a missing scope, the request limits and its body cap at the HTTP level, before any
tool runs, and every other failure as a tool error with the envelope's fields (06).
Order of work in every session: get_me (who am I, which scopes, which limits), then
forge_guide (the MDX format and the rules, as /guide.md), then
list_tiers if the post is gated, then write.
A post is one MDX text: a YAML frontmatter block (title, slug, summary, visibility,
min_tier, tags, license, rating, fields, gate, gate_tier, category, cover,
cover_alt, project; publish_at is ignored), then Markdown with ten components. No HTML, no imports, no
expressions: nothing an author writes is executed. The workflow is create, update, validate,
preview, publish. Publishing requires that the validated version equals the current version,
and a rating (g, pg, pg13, r, x; /ratings has the guidelines;
E_RATING_REQUIRED). Every post object carries body_sha256, the SHA-256 of the exact stored bytes, which a client can check,
and license (default: the maker's default licence).
MCP, Claude Code:
claude mcp add --transport http forge https://<domain>/mcp --header "Authorization: Bearer $FORGE_KEY"A person runs it, since it stores the key in Claude Code's configuration. Other MCP clients (Cursor, Claude Desktop, claude.ai): the guide's Connect an agent.
REST, who am I:
curl -sS "https://<domain>/api/v1/me" -H "Authorization: Bearer $FORGE_KEY"REST, create a draft from a file. Idempotency-Key is any string of your choice, scoped to
your maker: the same key with the same text returns the same post, the same key with a
different text is refused with E_IDEMPOTENCY_MISMATCH. A hash of the file is a good key. The
JSON goes to curl on stdin (--data-binary @-), never as one argument: a long post does not
fit in one, and -d would drop the line breaks.
jq -n --rawfile body post.mdx '{body_mdx: $body}' | curl -sS -X POST "https://<domain>/api/v1/posts" \ -H "Authorization: Bearer $FORGE_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(sha256sum post.mdx | cut -c1-64)" \ --data-binary @-REST, replace the text. If-Match carries the version you last saw; a stale value answers 412
E_VERSION_CONFLICT. Without the header the current version is used.
jq -n --rawfile body post.mdx '{body_mdx: $body}' | curl -sS -X PUT "https://<domain>/api/v1/posts/$POST_ID" \ -H "Authorization: Bearer $FORGE_KEY" \ -H "Content-Type: application/json" \ -H "If-Match: \"$VERSION\"" \ --data-binary @-REST, validate, then publish. Validate returns ok, errors, warnings, each error with
code, message, fix, line and column. Publish asks for the validated version.
curl -sS -X POST "https://<domain>/api/v1/posts/$POST_ID/validate" -H "Authorization: Bearer $FORGE_KEY" \ -H 'Content-Type: application/json' -d '{}'curl -sS -X POST "https://<domain>/api/v1/posts/$POST_ID/publish" \ -H "Authorization: Bearer $FORGE_KEY" -H "If-Match: \"$VERSION\"" \ -H 'Content-Type: application/json' -d '{}'A published post stays published while you edit it: an update to its text, title, summary,
visibility or tier is public once it is saved and validated, and the next publish_post
re-renders the share card and records the version. Ask the person before you update a
published post.
Routes: GET /api/v1/me, GET /api/v1/guide, GET and POST /api/v1/posts, GET and
PUT /api/v1/posts/{id} (the slug works in place of the id), POST /api/v1/posts/{id}/validate,
POST /api/v1/posts/{id}/preview, POST /api/v1/posts/{id}/publish,
POST /api/v1/posts/{id}/unpublish, the project routes (07), POST /api/v1/assets
(source_url and alt),
PUT /api/assets/{filename} (image bytes, ?alt=), GET and PUT /api/v1/tiers,
PUT /api/v1/tiers/{id}, GET /api/v1/stats, GET /api/v1/export. GET, POST and PUT
only; unpublish is a POST; nothing is deleted. Bodies are JSON with unknown fields refused.
No CORS. The full description with schemas is /api/v1/openapi.json.
Before publish_post, unpublish_post or close_project, confirm with the person: MCP clients mark all three anthropic/requiresUserInteraction, and a script should ask too.
Moderation: a post under a rule action is held. publish_post and update_post on it, and
create_post while the maker's new posts are held or the maker is suspended, answer
E_MODERATION_HOLD with the notice URL in fix. The person reads the notice at
/studio/notices/<id>, replies within the window or appeals; publishing waits until the action is lifted. No tool can lift
a hold; do not try to route around it with a new post or a new key.
Skill: the workflow and two scripts (ff-check.sh tests the key, ff-publish.sh runs the
workflow over REST) ship as the ZIP
/skills/freedom-forge-publisher/freedom-forge-publisher.zip.
curl -sSLo freedom-forge-publisher.zip https://<domain>/skills/freedom-forge-publisher/freedom-forge-publisher.zipunzip -o freedom-forge-publisher.zip -d ~/.claude/skills # Claude Code; .agents/skills for Codex-style agentsV05 Limits
One table, stated once in the guide and repeated here. Every limit is enforced by the api.
get_me returns the same numbers as limits.
| limit | value |
|---|---|
| post text | 256 KiB |
| any single block | 64 KiB |
| code blocks per post | 200 |
| embeds per post | 20 |
| gated blocks per post | 40 |
| GitHubCode lines | 400 |
inline image upload (data_base64) | 1 MiB |
image upload by source_url or PUT /api/assets/<filename> | 16 MiB |
| stored image width | 2560 px |
| embed fetch time | 5 s |
| embed fetch size | 1 MiB |
| embed redirects | 3 |
| requests per key | 60 a minute by default, up to 120 (set per key) |
| posts per key | 24 a day by default, up to 48 |
| images per key | 96 a day by default, up to 200 |
| publishes per key | 10 a minute |
| list page | 100 items |
| result text | 40,000 characters |
| REST request body | 1 MiB |
Idempotency-Key length | 128 characters |
Idempotency-Key memory | 24 hours |
| exports per account | 1 a day |
| export link | 72 hours, one download |
| MCP JSON-RPC batch | 10 messages; each counts against requests per key |
image uploads by source_url or PUT /api/assets | one at a time per maker |
| project goals | 5 per project |
| wallets | 5 per project |
| requests per page | 240 a minute over all its keys |
| posts per page | 96 a day over all its keys |
| images per page | 400 a day over all its keys |
| other writes per page | 300 a day over all its keys |
| export images | 80 MiB; later images are listed, not included |
| image upload body time | 10 s plus 1 s per 256 KiB, at most 55 s |
validate_post time | 8 s |
| contribution | whole dollars, within get_me.limits.project |
Per-key rows are a key's default and ceiling; its own numbers are in get_me (key), the
ceilings in limits.keys. Page rows count every key of the page, and the page's totals pause
nothing. A day ends at 00:00 UTC; 06 says how to read a 429. unpublish_post is not counted. Lists page with offset; get_post pages with body_offset.
VI06 Errors
Every failure under /api/v1 and /api/assets, and every MCP tool error, reads
E_CODE: what. Fix: how. REST answers one envelope with the HTTP status; an MCP tool answers
isError: true with code, message, fix, details and request_id in
structuredContent, including E_MODERATION_HOLD for a suspended maker's write.
Some MCP answers come before any tool runs, as plain HTTP with no envelope and no request_id:
a missing or bad key is 401 with WWW-Authenticate: Bearer error="invalid_token" and
{"error": "invalid_token"}; a key never given a tool's scope is 403
{"error": "insufficient_scope", "scope": ...}; the requests a minute per key, and repeated
failed keys from one address, are 429 {"error": "rate_limited"} with Retry-After; a body
over 2 MiB is 413 {"error": "payload_too_large"}. The key limits answer in the same plain
way but add code, fix, details and request_id: a paused key, 429 rate_limited with
details.paused and no Retry-After; a spent day, 429 with a details.limit ending in
_per_day and details.resets_at; an admin's pause, 403 keys_paused. A request with a
refused write is refused whole.
{ "code": "E_MODERATION_HOLD", "message": "This post is under a rule A6 action.", "fix": "Read the notice at https://<domain>/studio/notices/01JBE1V5C2N7XQ4Z9M0P8R6TWA, reply or appeal.", "details": { "rule_id": "A6", "action_id": "01JBE1V5C2N7XQ4Z9M0P8R6TWA" }, "request_id": "01JBE1W0Q3R8S5T2V9V4W1X6YZ"}| code | status | meaning | fix |
|---|---|---|---|
E_INVALID_KEY | 401 | the key is missing, malformed, unknown, expired or revoked (REST; MCP answers the plain 401 above) | a person creates a key at /studio/keys; check FORGE_KEY is set and complete |
E_INSUFFICIENT_SCOPE | 403 | the key was never given the scope in details (REST; MCP answers the plain 403 above; a suspended maker's write answers E_MODERATION_HOLD) | a person creates a key with that scope |
E_FORBIDDEN | 403 | the key creator's role on a team page does not allow the act | ask an owner to do it or to change the role |
E_ORIGIN | 403 | a browser Origin from another site | call from a script or server, not a web page |
E_INVALID_INPUT | 400 | the body or query does not match the schema, or has an unknown field | follow /api/v1/openapi.json |
E_NOT_FOUND | 404 | no such post, project, tier or asset for this maker (a post that is not a project is not found by the project tools) | check the id or slug |
E_METHOD | 405 | wrong HTTP method | GET, POST or PUT as the route table says |
E_LENGTH_REQUIRED | 411 | PUT /api/assets/{filename} without Content-Length | send the header |
E_TOO_LARGE, E_BODY_TOO_LARGE | 413 | the body is over a limit in 05 (E_BODY_TOO_LARGE: post text over 256 KiB of UTF-8) | split the post or send the image by source_url |
E_UNSUPPORTED_MEDIA | 415 | the Content-Type is not JSON (or not an image on the asset route) | set the header |
E_IDEMPOTENCY_MISMATCH | 409 | the same Idempotency-Key was used with a different text | use a new key for a new text |
E_VERSION_CONFLICT | 412 (REST), 409 (MCP) | If-Match or expected_version is stale | fetch the post, merge, retry with the current version |
E_NOT_VALIDATED | 409 | publish before validate, or an edit after validation | validate the current version, then publish |
E_VALIDATION_FAILED | 409 | publish of a version whose last validation reported errors | fix them, validate again |
E_ALREADY_PUBLISHED | 409 | a preview of a published post; previews are for drafts | open the public URL in fix; ask the person before an update, which goes live at once |
E_NOT_PUBLISHED | 409 | an unpublish of a post that is not published | nothing to take down |
E_SLUG_TAKEN, E_SLUG_LOCKED | 409 | the slug belongs to another post, or changed after the first publish | choose another slug, or keep the published one |
E_MODERATION_HOLD | 409 | the post or the maker is under a rule action (a suspended maker's writes too; fix names the suspension notice) | a person reads the notice at the URL in fix; no tool lifts a hold |
E_FRONTMATTER, E_MDX_SYNTAX, E_EXPRESSION, E_ESM, E_RAW_HTML, E_UNKNOWN_COMPONENT, E_COMPONENT_*, E_ATTRIBUTE_*, E_CODE_*, E_ALT_REQUIRED, E_IMAGE_SRC, E_EMBED_*, E_TOO_MANY_*, E_BLOCK_TOO_LARGE, E_CSS_TOO_LARGE, E_RATING_*, E_FIELDS, E_GATE_*, E_COVER, E_COVER_ALT, E_PROJECT_*, E_CATEGORY_UNKNOWN | 422 | the text breaks a rule of the guide; each comes with line:column | fix the text as the guide says, validate again |
E_CSS | 422 | the platform built styles outside its own allow-list; the platform's fault, not the text's | report it with the request_id |
E_TIER_LIMIT | 409 | a fifth active tier | save it with active: false, or switch another off first |
E_TIER_LOCKED | 409 | a change of a tier's slug or currency after supporters joined it | keep them; create a new tier instead |
E_TIER_UNKNOWN | 422 | min_tier is not one of this maker's tiers | use a slug from list_tiers |
E_GOAL_REACHED | 409 | a reached goal cannot change: set_goals changes its amount or removes it | add a goal above it instead |
E_PROJECT_CLOSED | 409 | the project is closed (what a contributor meets; an agent never funds) | nothing; the maker closed funding |
E_PROJECT_AMOUNT | 400 | the amount is outside the project's bounds (what a contributor meets) | a whole-dollar amount within the bounds get_me reports as limits.project |
E_KEYS_PAUSED | 403 | This maker's keys are paused by an admin (writes only; reads are served) | nothing from an agent; the maker reads the notice in the Studio |
E_INVITE_INVALID, E_INVITES_FROZEN, E_INVITE_QUOTA | 400, 409 | for people at /apply or in the Studio, never an agent: the invite does not work, invites are paused, or all are out | none from an agent |
E_WALLET_LIMIT | 409 | a project holds five wallets (a person adds them in the Studio) | remove one first |
E_IMAGE_FORMAT | 415 | the uploaded bytes are not jpeg, png, webp, gif or avif, or not the declared type | upload one of those formats with its real type |
E_IMAGE_DECODE | 422 | the image could not be processed | re-export the file and upload it again |
E_QUOTA | 507 | the maker's image storage is full | remove unused assets |
E_BODY_TOO_COMPLEX | 422 | the text stacks markdown delimiters beyond any post | remove the repeats, or split the text |
E_TIMEOUT | 504 | validation (8 s), an image body or the image worker took too long | split the post, send the image faster, retry |
E_SOURCE_URL | 422 | the image URL is not https on an allowed host, or did not answer | use an allowed host, or send the bytes |
E_RATE_LIMITED | 429 | a minute or a day (the key's or the page's) is spent, a second upload or validation runs at once, or the key is paused (over MCP a tool answers only the publish and one-at-a-time limits; the rest is the plain 429 above) | details.paused: the person resumes the key; a _per_day limit: stop until resets_at; uploads_at_once: wait for the running call; else wait Retry-After, retry once |
E_UNAVAILABLE, E_INTERNAL | 503, 500 | the service could not answer, or the validation or image queue is full | retry in a moment; report a repeat with the request_id |
Warnings never block: W_HEADING_DEMOTED, W_WIDE_IGNORED, W_IMAGE_SIZE_UNKNOWN,
W_EMBED_STALE, W_EMBED_META, W_PUBLISH_AT_IGNORED, W_RATING_MISSING, W_COVER_SMALL.
VII07 Projects
A project is a post with goals: one to five levels in rising amounts, each with a title and a promise of what it delivers. Supporters fund it with one-time contributions that go straight to the maker; a contributor is anonymous unless they ask to be named. An agent writes and manages projects and never funds one.
Tools, with the scope each needs (03): create_project (body_mdx, goals, idempotency_key,
license), set_goals (post_id or slug, goals), list_projects, get_project (post_id
or slug) and close_project (post_id or slug). REST: POST /api/v1/projects (the same
Idempotency-Key rule as a post), GET /api/v1/projects, GET /api/v1/projects/{id} (the slug
works in place of the id), PUT /api/v1/projects/{id}/goals and POST /api/v1/projects/{id}/close.
A goal is { "amount_minor", "title", "promise" }: the total raised, in USD cents, at which the
goal is reached; a title of up to 60 characters; a promise of up to 280. The list is 1 to 5 goals
and the amounts must rise. kind is post or project, set at creation and never changed;
list_posts lists both.
jq -n --rawfile body project.mdx '{body_mdx: $body, goals: [ {amount_minor: 500000, title: "Open the shop", promise: "The doors open on the first of the month."}, {amount_minor: 1500000, title: "A second bench", promise: "A bench for a visiting maker."}]}' | curl -sS -X POST "https://<domain>/api/v1/projects" \ -H "Authorization: Bearer $FORGE_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(sha256sum project.mdx | cut -c1-64)" \ --data-binary @-Publish it as a post (validate, then publish_post); a project with no goal is refused with 400
E_INVALID_INPUT, "a project needs at least one goal before it is published". Its page is
/c/<handle>/projects/<slug>. get_project answers the goals (each with reached_at), what
the project has raised (raised_minor: paid contributions less refunds), contributors,
named, next_goal, top_goal and closed_at. set_goals replaces the whole list; a goal that
has been reached keeps its amount and stays (E_GOAL_REACHED, 06): add a goal above it. A held
project, like a held post, answers E_MODERATION_HOLD.
close_project stops funding for good: the page stays, nothing is refunded, the api cannot
reopen it. Ask the person first.
A maker can show crypto wallets on a project; a person adds them in the Studio and no tool
does. get_project lists them as wallets (id, chain, label, address,
view_key_published, reported_minor, reported_at), never with a view key.
report_wallet_total (post_id or slug, wallet_id, minor) or
PUT /api/v1/projects/{id}/wallets/{wallet_id}/total ({ "minor" }) records what a wallet has
received in all, in USD cents as the maker converted it; it replaces the last figure, counts toward
the goals and is shown as reported by the maker. LotusMake takes no share of a wallet and
never sees it.
A contribution is whole dollars within the bounds get_me returns as limits.project
(min_minor, max_minor, goal_max_minor, cells_minor; minor units, from the platform
settings). LotusMake keeps a share of each contribution and Stripe's processing fee is passed
through; the project page names the share before the first click, so a post does not.