Content
Package content is marketing content from draft to published, on every channel.
Package content is marketing content from draft to published, on every channel.
| Base URL | https://api.hanzo.ai |
| Operations | 6 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1116 · Content — The Marketing Loop — Draft · read the specification →
/v1/content is the marketing content loop: draft with the model and the
studio, move an item through one lifecycle, publish it to the brand's connected
channels, and read the queue board across doctypes. It is implemented in
hanzoai/cloud apps/content as a stateless orchestrator: the framework
module marketing holds the documents, this capability adds only what a
generic document engine cannot be — the state machine, the generation edge, the
distribution edge (apps/content/content.go:21-41).
Motivation
CRUD, tenancy, permissions and install are the framework's generic surface; duplicating them here would be a second document engine to keep honest. What the generic engine cannot know is that a marketing item has exactly one legal lifecycle, that a draft can be generated, and that "published" means external side effects happened — those three are this capability.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
§1 It owns no store
Documents live in the framework module marketing; this subsystem opens no
store of its own (apps/content/content.go:40-41). Its state is the swappable
edges — generator, distributor, storefront (apps/content/content.go:47-52).
§2 The lifecycle is enforced at the storage boundary
For every publishable doctype, before_save hooks enforce two rules no matter
who writes — the /v1/content endpoints, a raw framework PUT, the console's
generic renderer, or an automations flow step
(apps/content/hooks.go:13-26):
- status-edge legality — the one state machine in
lifecycle.go; - server-owned fields (
external_ids,published_at) may only be written by a trusted in-process op, never by a client.
Both are pure gates: a refused write is 422, and distribution stays in the orchestrator, never in the engine.
§3 The addresses, and the one implementation
GET /v1/content/lifecycle, GET /v1/content/board,
GET /v1/content/channels, POST /v1/content/generate,
POST /v1/content/publish, and
POST /v1/content/{doctype}/{name}/transition — all typed
(apps/content/typed_wire_test.go). Every exported op is called both by the
handler and by the automations connector, so a human console, a flow, an MCP
tool call and a headless bot drive the same single implementation
(apps/content/content.go:36-39). Publish is idempotent per channel and
answers in_progress having posted nothing when it loses the per-item lease to
a live publisher (apps/content/zipdoc_gen.go:71).
§4 Tenancy and money
Every handler resolves its tenant through principal.Org (HIP-0026) and scopes
strictly to it (apps/content/content.go:34-35). The capability is metered
(plugin/content/main.go:21), and the meter is split on who can attribute the
work: text drafting rides the platform AI plane — identity travels on the
ChatRequest, so the one inference meter bills the tokens
(apps/content/generate.go:27-31) — while studio renders are GPU work the AI
plane never sees, so content is their sole meter: Bill.Gate before the render
(fail-closed 402) and the debit recorded after
(apps/content/studio_render.go:121-123,172). The metered-apps registry
carries content for exactly that render fee.
§5 Events, telemetry, stage, upstreams
It publishes no events on the platform bus; a publish's external side effects
are posts to the org's own connected channels through the distributor edge. It
emits nothing to observability beyond the request span — inference telemetry
belongs to the AI plane that serves it. Its stage is beta: a vertical
application. It derives from no upstream; the document engine is our own
framework module.
Rationale
Enforcing the lifecycle in before_save hooks rather than in the handlers is
the load-bearing choice: the framework surface is generic and open, so a rule
enforced only in this package's handlers is a rule any other writer skips. At
the storage boundary there is no other writer. The cost is that the hooks must
stay pure — side effects in a gate would fire on every writer too — which is
why distribution lives in the orchestrator.
Security Considerations
The wrong implementation lets a client write external_ids or published_at
directly — forging the record that something was published, or aiming a
re-publish at someone else's external post ids. The server-owned-fields gate
exists for that. The render meter is the second exposure: GPU work billed to
nobody is free compute, so the gate runs before the render and fails closed on
a frozen or broke org. Tenancy rides the validated principal on every path,
including the connector-driven ones.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | content at its own prefix | 6 operations |
| CLI | hanzo content … | 6 of 6 |
| SDK | ContentApi in every published client | 6 methods |
| MCP | tool content on https://api.hanzo.ai/v1/mcp | 6 operations, 2 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/content/board, operation get_content_board:
hanzo content boardimport { Configuration, ContentApi } from 'hanzoai';
const api = new ContentApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getContentBoard();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import ContentApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = ContentApi(client).get_content_board()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.ContentAPI.GetContentBoard(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, content_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = content_api::get_content_board(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.ContentApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new ContentApi(client).getContentBoard();curl https://api.hanzo.ai/v1/content/board \
-H "Authorization: Bearer $HANZO_API_KEY"Tool content, op get_content_board — POST the JSON-RPC envelope to https://api.hanzo.ai/v1/mcp.
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Authorization: Bearer $HANZO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "content",
"arguments": {
"op": "get_content_board",
"input": {}
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
POST /v1/content/{doctype}/{name}/transition | Moves one content item to a new lifecycle state and, on the move to published, fans it out to the item's channels. |
GET /v1/content/board | Aggregates the caller org's marketing content across every publishable content type into ONE queue board — the cross-type read the framework's… |
GET /v1/content/channels | Lists the distribution channels the caller's org has connected — the social integrations a publish can target. |
POST /v1/content/generate | Draft a piece of marketing content and file it in the CMS as a draft. |
GET /v1/content/lifecycle | Returns the ONE marketing-content state machine: the ordered lifecycle states, which state a fresh document starts in, which one is publicly live,… |
POST /v1/content/publish | Publish distributes one CMS content item to the channels recorded on it and returns the honest per-channel outcome. |
How is this guide?