The Horns server
Mount the Horns routes on a Hono app in a Cloudflare Worker with @manta-eu/horns-server.
What it is
@manta-eu/horns-server is the server half of Horns, as Hono routes you mount on your own Worker. For every request
it checks that the call comes from your own origin, that the viewer is signed in, and that they may use the datastore
in the path. Then it runs a Horn’s queries against Manta as that viewer, saves Horns as revisions in the datastore,
and hosts the Studio, where people build Horns with an AI model.
You mount only the routes you need:
| Routes | What they serve |
|---|---|
hornRoutes() |
Read, save, snapshot and render Horns. Every host needs these. |
actionPlanRoutes() |
Run an action plan from a view, for a host that lets people act on what they see |
studioRoutes() |
The Studio’s edit, review and shape requests |
studioAgentRoutes() |
The Studio conversation, held in a Durable Object |
Import
import { actionPlanRoutes, hornRoutes, studioRoutes, type Env } from "@manta-eu/horns-server";
import { provideStudioDeps, studioAgentRoutes } from "@manta-eu/horns-server/studio-agent";
Env is every binding the routes read. A Worker that mounts only hornRoutes() can use the narrower HornRoutesEnv.
Example
import { Hono } from "hono";
import type { Realm } from "@manta-eu/horns";
import { shapesForRealm } from "./horns/shapes";
const shapes = (realm: Realm) => shapesForRealm(realm).shapes;
provideStudioDeps({ shapes });
const app = new Hono<{ Bindings: Env }>()
.route("/", studioAgentRoutes<Env>())
.route("/", hornRoutes<Env>())
.route("/", actionPlanRoutes<Env>())
.route("/", studioRoutes<Env>({ shapes }));
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
export { HornStudioAgent } from "@manta-eu/horns-server/studio-agent-class";
export default app;
shapesForRealm returns your realm’s shape catalog, which you build from your @manta-eu/ldo schemas with
shapeCatalogFromLdkit, and shapes hands the Studio its shapes. That is how the Studio knows what your data looks
like.
The Studio keeps each conversation in a Durable Object. Bind it in wrangler.jsonc:
{
"durable_objects": { "bindings": [{ "name": "HORN_STUDIO", "class_name": "HornStudioAgent" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["HornStudioAgent"] }],
}
Without the binding the rest of Horns works, and the Studio routes answer 503.
Request
The routes read their configuration from the Worker’s environment:
| Binding | Needed for |
|---|---|
ASSETS |
The static assets, including the /horn.html page the render route fills |
VITE_KEYCLOAK_REALM |
Every route. The realm this Worker serves |
MANTA_AGENTS_URL |
Every route. The Manta API that queries run against |
SPARQL_UPDATE_PSK |
Saving Horns |
HORN_STUDIO |
The Studio conversation |
HORNS_LLM_BASE_URL |
The Studio model through Cloudflare AI Gateway: your gateway’s URL |
HORNS_LLM_MODEL |
The Studio model through Cloudflare AI Gateway: the model name |
CLOUDFLARE_AI_GATEWAY_TOKEN |
The Studio model through Cloudflare AI Gateway: the gateway token |
GOOGLE_VERTEX_PROJECT |
The Studio model on Vertex instead |
GOOGLE_VERTEX_LOCATION |
The Studio model on Vertex instead |
GOOGLE_VERTEX_MODEL |
The Studio model on Vertex instead |
VERTEX_SERVICE_ACCOUNT |
The Studio model on Vertex instead: the service account key |
HORNS_SESSION_COOKIE_PREFIX |
The cookie that holds the viewer’s session, for a host that signs people in itself |
SENTRY_DSN |
Error reporting. contentSecurityPolicy() lets the page reach its Sentry host |
Setting any of the four Vertex bindings picks Vertex over the gateway, and then all four are required. The gateway URL
must be on gateway.ai.cloudflare.com, so every model call is logged.
SPARQL_UPDATE_PSK, CLOUDFLARE_AI_GATEWAY_TOKEN and VERTEX_SERVICE_ACCOUNT are secrets. Set them with wrangler secret put, never in wrangler.jsonc, and never send them to the browser.
These are the routes a browser calls. Every one takes the datastore id in the path and the viewer’s session cookie:
| Route | Body |
|---|---|
GET /api/d/:datastoreId/horns |
none |
GET /api/d/:datastoreId/horns/:slug |
none |
PUT /api/d/:datastoreId/horns/:slug |
{ horn, baseRevision? } |
GET /api/d/:datastoreId/horns/by/about?iri= |
none |
GET /api/d/:datastoreId/horns/by/about/list?iri= |
none, with limit from 1 to 10 |
POST /api/d/:datastoreId/horns/snapshot |
{ horn, variables?, viewIds? } |
POST /d/:datastoreId/horns/render?mode=embed|page |
{ horn, variables?, viewIds? } |
POST /api/d/:datastoreId/action-plans/run |
{ plan, preview } |
Response
| Route | Answers with |
|---|---|
GET …/horns |
{ horns: [{ slug, iri, title, revision, number }] }, the saved Horns |
GET …/horns/:slug |
{ horn, revision, contentHash }, or 404 |
PUT …/horns/:slug |
{ iri, slug, revision, contentHash }. See saving a Horn |
GET …/horns/by/about |
{ horn, kept, … }, the saved Horn about the IRI or one built for it, or 404 |
GET …/horns/by/about/list |
{ horns, total }. See HornsAbout |
POST …/horns/snapshot |
{ snapshot }, a HornSnapshot |
POST …/horns/render |
text/html: /horn.html with the embed in it |
POST …/action-plans/run |
202 and { started: true, workflowId }. preview: true checks every change, makes none |
JSON answers are never cached. Every error is { "error": "…" }:
400: the body failed validation. The message lists the first problems.401: the viewer is not signed in.403: the request came from another origin, or the viewer may not use this datastore.500: the Worker has no realm configured.502: Manta could not be reached.