Quick start
From a blank project to a todo app whose data lives in Manta, with a Manta chat that answers questions about it.
This guide builds a small todo app on your own data. The todos live in your Manta datastore. Your server writes them, your React app reads them with typed schemas, and the Manta chat in the same page answers questions about them.
You build three pieces:
- A vocabulary: the
Todoclass and its properties, in a namespace that you own. - A server: it signs people in, gives the page a Manta token, and writes todos with SPARQL updates.
- A React app: a todo list that reads the datastore, and the Manta chat next to it.
The guide uses Vite and React for the app, and Hono on Node for the server. Any framework works: the server pieces speak
standard Request and Response.
Get access
Manta sets up your organization by hand for now. Contact us for access, and send the redirect URL of your app:
http://localhost:5173/callback for this guide. You get back:
- a Manta account, as a member of your new organization;
- a datastore for your data, and its id;
- a custom app with User access, Machine access and SPARQL writes, and its client ID and secret.
The custom app is how your code talks to Manta. Manta shows its secret once: keep it on your server. The custom apps guide explains each setting.
Set up the project
-
Create a Vite React app and add the SDK.
pnpm create vite todos --template react-ts cd todos pnpm add @manta-eu/react @manta-eu/auth next-themes @xyflow/react html-to-image @ulb-darmstadt/shacl-form pnpm add hono @hono/node-server pnpm add -D @manta-eu/cli @manta-eu/codegen tailwindcss @tailwindcss/vite tsx -
Keep the custom app’s settings in
.envon your machine. Vite gives the page only theVITE_variables, so the datastore id reaches it and the secret stays on the server.MANTA_DATASTORE=your-datastore VITE_MANTA_DATASTORE=your-datastore MANTA_CLIENT_ID=ext-your-org-1a2b3c MANTA_CLIENT_SECRET=the-secret-from-manta SESSION_SECRET=at-least-32-random-characters-here -
Register Tailwind, and send the server’s routes from the Vite dev server to the server.
// vite.config.ts import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; const server = "http://localhost:8787"; export default defineConfig({ plugins: [react(), tailwindcss()], server: { proxy: { "/api": server, "/login": server, "/callback": server, "/logout": server } }, }); -
Add the Manta styles to
src/index.css.@import "tailwindcss"; @import "@manta-eu/react/globals.css";
Describe your data
The datastore needs to know what a todo is. You declare it in a vocabulary and its SHACL shapes, both in a namespace
that your app owns, and push them with the manta CLI.
-
Write the vocabulary in
manta/ontology.ttl.@prefix todo: <https://todos.example.com/vocab#> . @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> . @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . @prefix xsd: <http://www.w3.org/2001/XMLSchema#> . todo:Todo a rdfs:Class ; rdfs:label "Todo" ; rdfs:comment "Something to do." . todo:title a rdf:Property ; rdfs:domain todo:Todo ; rdfs:range xsd:string ; rdfs:label "Title" . todo:completed a rdf:Property ; rdfs:domain todo:Todo ; rdfs:range xsd:boolean ; rdfs:label "Completed" .Use a domain that you own for the namespace. Manta refuses a namespace that overlaps one it or another app uses.
-
Write the shapes in
manta/shapes.ttl. The shapes tell codegen which properties a todo has.@prefix todo: <https://todos.example.com/vocab#> . @prefix sh: <http://www.w3.org/ns/shacl#> . @prefix xsd: <http://www.w3.org/2001/XMLSchema#> . todo:TodoShape a sh:NodeShape ; sh:targetClass todo:Todo ; sh:property [ sh:path todo:title ; sh:datatype xsd:string ; sh:minCount 1 ; sh:maxCount 1 ] ; sh:property [ sh:path todo:completed ; sh:datatype xsd:boolean ; sh:maxCount 1 ] . -
Push both with the app’s machine token. With
MANTA_CLIENT_SECRETset, the CLI asks for that token by itself.set -a; . ./.env; set +a pnpm manta ontology push manta/ontology.ttl --datastore $MANTA_DATASTORE --name todos \ --namespace https://todos.example.com/vocab# --client-id $MANTA_CLIENT_ID pnpm manta shapes push manta/shapes.ttl --datastore $MANTA_DATASTORE --name todos --client-id $MANTA_CLIENT_IDset -aexports every variable in.env, the secret too, for the rest of this shell. Close the shell when you are done. A second push of the same files writes nothing, so the commands fit a CI step. -
Generate the typed schemas from the shapes.
pnpm manta-codegen generate --datastore $MANTA_DATASTORE --out ./src/.ldkit --client-id $MANTA_CLIENT_IDCodegen writes
src/.ldkit/todo.ts, named after thetodoprefix. It has one schema per class, named after the prefix and the class:TodoTodoSchema.
The CLI readme lists every check the API runs on a push.
Write the server
The server does three things: it signs people in through your custom app, it hands the page a fresh access token, and it writes todos. The person’s token carries the app’s SPARQL writes permission, so the writes run as that person. The page holds the same token and could write by itself. Writing through your server keeps the checks on what goes into the graph in one place.
// server/index.ts
import { serve } from "@hono/node-server";
import { webLoginRoutes } from "@manta-eu/auth/web";
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
const mantaUrl = "https://app.hiremanta.com";
const datastore = process.env.MANTA_DATASTORE!;
const vocab = "https://todos.example.com/vocab#";
const manta = webLoginRoutes({
client: { clientId: process.env.MANTA_CLIENT_ID!, clientSecret: process.env.MANTA_CLIENT_SECRET! },
appUrl: "http://localhost:5173",
sessionSecret: process.env.SESSION_SECRET!,
offlineAccess: true,
});
const app = new Hono();
app.on(
["GET", "POST"],
["/login", "/callback", "/logout"],
async (c) => (await manta.handle(c.req.raw)) ?? c.notFound(),
);
app.get("/api/manta-token", async (c) => {
const { accessToken, idToken, setCookies } = await manta.session(c.req.raw);
for (const cookie of setCookies) c.header("set-cookie", cookie, { append: true });
if (!accessToken || !idToken) return c.json({ error: "Sign in first" }, 401);
return c.json({ accessToken, user: personOf(idToken) });
});
app.post("/api/todos", async (c) => {
const { title } = await c.req.json();
if (typeof title !== "string" || title.trim() === "") throw new HTTPException(400, { message: "Give a title." });
const todo = `https://todos.example.com/todos/${crypto.randomUUID()}`;
return update(
c.req.raw,
`INSERT DATA { <${todo}> a <${vocab}Todo> ;
<${vocab}title> ${literal(title)} ; <${vocab}completed> false . }`,
);
});
app.patch("/api/todos", async (c) => {
const { todo, completed } = await c.req.json();
checkTodoIri(todo);
if (typeof completed !== "boolean") throw new HTTPException(400, { message: "Give completed as true or false." });
return update(
c.req.raw,
`DELETE { <${todo}> <${vocab}completed> ?old }
INSERT { <${todo}> <${vocab}completed> ${completed} }
WHERE { <${todo}> <${vocab}completed> ?old }`,
);
});
app.delete("/api/todos", async (c) => {
const { todo } = await c.req.json();
checkTodoIri(todo);
return update(c.req.raw, `DELETE { <${todo}> ?property ?value } WHERE { <${todo}> ?property ?value }`);
});
async function update(request: Request, sparql: string): Promise<Response> {
const { accessToken, setCookies } = await manta.session(request);
const headers = new Headers();
for (const cookie of setCookies) headers.append("set-cookie", cookie);
if (!accessToken) return Response.json({ error: "Sign in first" }, { status: 401, headers });
const response = await fetch(`${mantaUrl}/d/${datastore}/sparql-update`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/sparql-update" },
body: sparql,
});
if (response.ok) return new Response(null, { status: 204, headers });
return new Response(await response.text(), { status: response.status, headers });
}
// JSON string escapes are valid SPARQL string escapes, so user input cannot end the literal.
function literal(value: string): string {
return JSON.stringify(value);
}
function checkTodoIri(todo: unknown): asserts todo is string {
const isTodoIri = typeof todo === "string" && /^https:\/\/todos\.example\.com\/todos\/[0-9a-f-]{36}$/.test(todo);
if (!isTodoIri) throw new HTTPException(400, { message: "Give the IRI of one of your todos." });
}
function personOf(idToken: string): { id: string; email: string; name?: string } {
const claims = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString());
return { id: claims.sub, email: claims.email, name: claims.name };
}
serve({ fetch: app.fetch, port: 8787 });
A custom app writes only to the datastore’s default graph. An update with GRAPH, WITH or USING answers 403.
Writing with SPARQL updates lists what else the API refuses.
Never put a value from a request into an update as it is. Check its type, build literals with literal(), and build
IRIs only from a checked pattern, as above. Send the cookies that session() returns with every response: when it renews the
session, the old refresh token works only once more.
Show the todos
The page gets its token from /api/manta-token. EmbeddedChatProviders sends it on every request to Manta, and gives
the hooks below their SPARQL connection.
-
Wrap the app in the providers.
// src/manta.tsx import { cachedToken } from "@manta-eu/auth/web"; import { EmbeddedChatProviders } from "@manta-eu/react/providers"; import type { ReactNode } from "react"; export type Person = { id: string; email: string; name?: string }; export const datastoreId = import.meta.env.VITE_MANTA_DATASTORE; export const getAccessToken = cachedToken(async () => (await mantaSession()).accessToken); export async function mantaSession(): Promise<{ accessToken: string; user: Person }> { const response = await fetch("/api/manta-token"); if (response.status === 401) { window.location.assign("/login"); throw new Error("Sending you to sign in."); } if (!response.ok) throw new Error(`/api/manta-token answered ${response.status}. Check that the server runs.`); return response.json(); } export function MantaProviders(props: { children: ReactNode }) { return ( <EmbeddedChatProviders datastoreId={datastoreId} getAccessToken={getAccessToken}> {props.children} </EmbeddedChatProviders> ); } -
Read the todos with
useLensFind, and change them through your server. After each write, refetch the list.// src/todo-list.tsx import { Button } from "@manta-eu/react/button"; import { Checkbox } from "@manta-eu/react/checkbox"; import { Input } from "@manta-eu/react/input"; import { useLensFind, useSparqlOptions } from "@manta-eu/react/ldo-ui"; import { useState } from "react"; import { TodoTodoSchema } from "./.ldkit/todo"; export function TodoList() { const options = useSparqlOptions(); const { data: todos = [], refetch } = useLensFind({ schema: TodoTodoSchema, options, queryKey: ["todos"] }); const [title, setTitle] = useState(""); async function send(path: string, method: string, body?: object) { const response = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!response.ok) throw new Error(`${method} ${path} answered ${response.status}.`); await refetch(); } return ( <section className="flex flex-col gap-3"> <form className="flex gap-2" onSubmit={(event) => { event.preventDefault(); void send("/api/todos", "POST", { title }).then(() => setTitle("")); }} > <Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="What needs doing?" /> <Button type="submit">Add</Button> </form> <ul className="flex flex-col gap-2"> {todos.map((todo) => ( <li key={todo.$id} className="flex items-center gap-2"> <Checkbox checked={todo.completed ?? false} onCheckedChange={(checked) => send("/api/todos", "PATCH", { todo: todo.$id, completed: checked === true }) } /> <span className="typography-body flex-1">{todo.title}</span> <Button variant="ghost" size="sm" onClick={() => send("/api/todos", "DELETE", { todo: todo.$id })}> Delete </Button> </li> ))} </ul> </section> ); }The page sends each todo’s IRI back as it is. The server accepts only an IRI of the shape it mints.
Add the chat
The chat runs as the person who signed in, on the same datastore. It reads the graph, so it can answer questions about the todos.
// src/App.tsx
import { EmbeddedChat } from "@manta-eu/react/components/embedded-chat";
import { useEffect, useState } from "react";
import { MantaProviders, datastoreId, mantaSession, type Person } from "./manta";
import { TodoList } from "./todo-list";
export default function App() {
const [user, setUser] = useState<Person>();
const [failure, setFailure] = useState<string>();
useEffect(() => {
mantaSession()
.then((session) => setUser(session.user))
.catch((error: Error) => setFailure(error.message));
}, []);
if (failure) return <p className="typography-body p-6">{failure}</p>;
if (!user) return null;
return (
<MantaProviders>
<main className="grid h-screen grid-cols-[1fr_420px] gap-6 bg-background p-6">
<TodoList />
<EmbeddedChat
user={user}
isOperator={false}
datastore={{ id: datastoreId, name: "Todos" }}
assistantName="Todo assistant"
welcomeSubtitle="Ask about your todos."
/>
</main>
</MantaProviders>
);
}
Run it
pnpm tsx --env-file=.env server/index.ts
pnpm dev
Open http://localhost:5173. The page sends you to Manta to sign in, then back. Add a few todos, then ask the chat
“Which of my todos are still open?”.
What’s next
- Derive facts with rules. Datalog rules can keep counts, statuses and other facts up to date for you. For now an
operator pushes them: contact us with your
.dlogfile. See Rules. - Let the agent change your data. The chat in a custom app reads your data and can run your actions, but does not write raw facts.
- See every option for sign-in, tokens and limits in the custom apps guide.