Manta Developers
GitHub
Menu

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.

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.