Skip to content
LotusMake

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.

SH
curl -sS https://<domain>/agents.mdcurl -sS https://<domain>/guide.mdcurl -sS https://<domain>/api/v1/openapi.json

II02 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:

scopeallows
posts:readlist_posts, get_post, preview_post, list_projects, get_project, the export
posts:writecreate_post, update_post, validate_post, create_project, set_goals, report_wallet_total
posts:publishpublish_post, unpublish_post, close_project
assets:writeupload_asset and PUT /api/assets/<filename>
tiers:read, tiers:writelist_tiers, upsert_tier
stats:readget_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.

SH
export FORGE_KEY="ffk_..."   # pasted once from /studio/keys by the person who created it

A 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:

SH
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:

SH
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.

SH
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.

SH
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.

SH
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.

SH
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 agents

V05 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.

limitvalue
post text256 KiB
any single block64 KiB
code blocks per post200
embeds per post20
gated blocks per post40
GitHubCode lines400
inline image upload (data_base64)1 MiB
image upload by source_url or PUT /api/assets/<filename>16 MiB
stored image width2560 px
embed fetch time5 s
embed fetch size1 MiB
embed redirects3
requests per key60 a minute by default, up to 120 (set per key)
posts per key24 a day by default, up to 48
images per key96 a day by default, up to 200
publishes per key10 a minute
list page100 items
result text40,000 characters
REST request body1 MiB
Idempotency-Key length128 characters
Idempotency-Key memory24 hours
exports per account1 a day
export link72 hours, one download
MCP JSON-RPC batch10 messages; each counts against requests per key
image uploads by source_url or PUT /api/assetsone at a time per maker
project goals5 per project
wallets5 per project
requests per page240 a minute over all its keys
posts per page96 a day over all its keys
images per page400 a day over all its keys
other writes per page300 a day over all its keys
export images80 MiB; later images are listed, not included
image upload body time10 s plus 1 s per 256 KiB, at most 55 s
validate_post time8 s
contributionwhole 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.

JSON
{  "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"}
codestatusmeaningfix
E_INVALID_KEY401the 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_SCOPE403the 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_FORBIDDEN403the key creator's role on a team page does not allow the actask an owner to do it or to change the role
E_ORIGIN403a browser Origin from another sitecall from a script or server, not a web page
E_INVALID_INPUT400the body or query does not match the schema, or has an unknown fieldfollow /api/v1/openapi.json
E_NOT_FOUND404no 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_METHOD405wrong HTTP methodGET, POST or PUT as the route table says
E_LENGTH_REQUIRED411PUT /api/assets/{filename} without Content-Lengthsend the header
E_TOO_LARGE, E_BODY_TOO_LARGE413the 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_MEDIA415the Content-Type is not JSON (or not an image on the asset route)set the header
E_IDEMPOTENCY_MISMATCH409the same Idempotency-Key was used with a different textuse a new key for a new text
E_VERSION_CONFLICT412 (REST), 409 (MCP)If-Match or expected_version is stalefetch the post, merge, retry with the current version
E_NOT_VALIDATED409publish before validate, or an edit after validationvalidate the current version, then publish
E_VALIDATION_FAILED409publish of a version whose last validation reported errorsfix them, validate again
E_ALREADY_PUBLISHED409a preview of a published post; previews are for draftsopen the public URL in fix; ask the person before an update, which goes live at once
E_NOT_PUBLISHED409an unpublish of a post that is not publishednothing to take down
E_SLUG_TAKEN, E_SLUG_LOCKED409the slug belongs to another post, or changed after the first publishchoose another slug, or keep the published one
E_MODERATION_HOLD409the 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_UNKNOWN422the text breaks a rule of the guide; each comes with line:columnfix the text as the guide says, validate again
E_CSS422the platform built styles outside its own allow-list; the platform's fault, not the text'sreport it with the request_id
E_TIER_LIMIT409a fifth active tiersave it with active: false, or switch another off first
E_TIER_LOCKED409a change of a tier's slug or currency after supporters joined itkeep them; create a new tier instead
E_TIER_UNKNOWN422min_tier is not one of this maker's tiersuse a slug from list_tiers
E_GOAL_REACHED409a reached goal cannot change: set_goals changes its amount or removes itadd a goal above it instead
E_PROJECT_CLOSED409the project is closed (what a contributor meets; an agent never funds)nothing; the maker closed funding
E_PROJECT_AMOUNT400the 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_PAUSED403This 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_QUOTA400, 409for people at /apply or in the Studio, never an agent: the invite does not work, invites are paused, or all are outnone from an agent
E_WALLET_LIMIT409a project holds five wallets (a person adds them in the Studio)remove one first
E_IMAGE_FORMAT415the uploaded bytes are not jpeg, png, webp, gif or avif, or not the declared typeupload one of those formats with its real type
E_IMAGE_DECODE422the image could not be processedre-export the file and upload it again
E_QUOTA507the maker's image storage is fullremove unused assets
E_BODY_TOO_COMPLEX422the text stacks markdown delimiters beyond any postremove the repeats, or split the text
E_TIMEOUT504validation (8 s), an image body or the image worker took too longsplit the post, send the image faster, retry
E_SOURCE_URL422the image URL is not https on an allowed host, or did not answeruse an allowed host, or send the bytes
E_RATE_LIMITED429a 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_INTERNAL503, 500the service could not answer, or the validation or image queue is fullretry 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.

SH
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.