Manta Developers
GitHub
Menu

Custom apps

Sign people in to your own app with Manta, or give your server its own access, and call Manta's APIs.

A custom app is your own app, on your own domain, that calls Manta for one organization. An organization admin or a Manta operator creates it on the Custom apps page in Manta, and gets a client ID and a secret for it. The client ID looks like ext-<organization>-<16 hex digits>.

Every token a custom app gets is limited to the organization that owns it, whoever signs in.

Access

An app’s Access sets which tokens it can get and what they may do.

Access Who the token acts as How it gets one
User access The person who signed in The authorization code flow with PKCE (S256), on a redirect URL you set
Machine access The app itself, with no one signed in grant_type=client_credentials with the client ID and secret

An app has either or both, with the same client ID and secret. Whether it has user access cannot change later. Every app may run actions through GraphQL, and write memories with a person’s token. SPARQL writes also lets either token send SPARQL updates to its datastores; see Writing with SPARQL updates.

  • Person tokens reach what that person can open in the owning organization, and nothing in any other. Only members of that organization can sign in; anyone else is refused with invalid_grant.
  • Machine tokens act for the organization, with a member’s access to its datastores. They carry no refresh token: mint a new one when it runs out.

Pick an architecture

Your server signs people in, keeps their Manta tokens, and calls Manta for them. The browser only talks to your server, with your own session cookie, and never sees a Manta token or the secret.

webLoginRoutes does all of it: /login, /callback and /logout, the state check, and the session, kept in encrypted cookies. It speaks standard Request and Response, so it fits Next.js route handlers, Hono, Workers, Remix, Deno and Bun.

import { webLoginRoutes } from "@manta-eu/auth/web";

const manta = webLoginRoutes({
  client: { clientId: process.env.MANTA_CLIENT_ID, clientSecret: process.env.MANTA_CLIENT_SECRET },
  appUrl: "https://app.example.com", // its /callback must be one of the app's redirect URLs
  sessionSecret: process.env.SESSION_SECRET, // 32+ random characters, server-side only
  offlineAccess: true,
});

// Hono, for example: the three routes, then the person's token wherever you call Manta.
app.on(
  ["GET", "POST"],
  ["/login", "/callback", "/logout"],
  async (c) => (await manta.handle(c.req.raw)) ?? c.notFound(),
);

app.get("/api/report", async (c) => {
  const { accessToken, setCookies } = await manta.session(c.req.raw);
  for (const cookie of setCookies) c.header("set-cookie", cookie, { append: true });
  if (!accessToken) return c.json({ error: "Sign in first" }, 401);
  // ...call Manta with accessToken
});
  • Sign-in: /login?returnTo=/reports brings people back to that page. Only paths on your own app are accepted.
  • Sessions: session() renews the access token shortly before it expires and hands back the cookies to set; send them with your response.
  • Sign-out: /logout revokes the refresh token, so an offline session ends too, then signs the person out of Manta as well. Pass signOutOfManta: false to sign out of your app only.
  • Rotating sessionSecret signs everyone out.

Step by step

For full control over sessions, the same flow is available as single steps:

  1. Send the person to Keycloak. Keep state and verifier in their session.

    import { startWebLogin } from "@manta-eu/auth/web";
    
    const client = { clientId: process.env.MANTA_CLIENT_ID, clientSecret: process.env.MANTA_CLIENT_SECRET };
    
    const { url, state, verifier } = await startWebLogin(client, { redirectUri, offlineAccess: true });
  2. On the redirect URL, check state, then trade the code for tokens.

    import { finishWebLogin } from "@manta-eu/auth/web";
    
    const tokens = await finishWebLogin(client, { code, redirectUri, verifier });
  3. Renew the access token before its five minutes run out, and store the refresh token you get back in place of the old one. undefined means the person signs in again.

    import { refreshWebLogin } from "@manta-eu/auth/web";
    
    const renewed = await refreshWebLogin(client, session.refreshToken);
    if (!renewed) return redirectToSignIn();
    session.refreshToken = renewed.refreshToken;
    session.idToken = renewed.idToken ?? session.idToken;
  4. Call Manta with the access token as a bearer token, as in Calling Manta from a backend.

  5. Sign the person out. Your app and Manta’s web app share one Keycloak session, so choose how far it goes:

    import { endWebLogin, webLogoutUrl } from "@manta-eu/auth/web";
    
    // Out of your app only: revoke the refresh token. This is also the only way to end an offline session.
    await endWebLogin(client, session.refreshToken);
    
    // Out of Manta too: end the shared Keycloak session and return to your home URL. An open Manta tab
    // signs out at its next token renewal, within five minutes.
    redirect(webLogoutUrl(client, { idToken: session.idToken, postLogoutRedirectUri: homeUrl }));

    Without idToken, Keycloak asks the person to confirm before signing them out.

startWebLogin, finishWebLogin, refreshWebLogin, endWebLogin and webLogoutUrl target hiremanta.com production. Pass baseUrl and realm in client for another deployment.

To skip Manta’s sign-in card, pass the sign-in method in idpHint, for example startWebLogin(client, { redirectUri, idpHint: "google" }): people go straight to Google, and those already signed in to Manta still go straight through.

Browser calls Manta directly

When the SDK’s components run in your page, they can call Manta themselves with a token your server hands them. Your server still signs people in and holds the refresh token as above, and adds an endpoint that returns a fresh access token for the signed-in person:

import { cachedToken } from "@manta-eu/auth/web";
import { EmbeddedChat } from "@manta-eu/react/components/embedded-chat";
import { EmbeddedChatProviders } from "@manta-eu/react/providers";

const getAccessToken = cachedToken(async () => {
  const response = await fetch("/api/manta-token");
  if (!response.ok) throw new Error("Not signed in");
  return ((await response.json()) as { accessToken: string }).accessToken;
});

export function Copilot() {
  return (
    <EmbeddedChatProviders datastoreId="your-datastore" getAccessToken={getAccessToken}>
      <EmbeddedChat user={{ id: "…", email: "you@example.com", name: "You" }} isOperator={false} />
    </EmbeddedChatProviders>
  );
}

With getAccessToken, every request carries the bearer token instead of a Manta session cookie, and a 401 does not redirect to Manta’s login. Providers and MantaClientProvider take the same prop. The chat sends each new token to its open connection, so return a new token about a minute before the old one expires, as cachedToken does.

Pass onSignOut along with getAccessToken. Manta’s Log out calls it, so your app can clear its own session and send the person wherever signing out should go. Without it, Log out falls back to onUnauthorized, which Manta calls when your token stops working. If you pass neither, Log out only clears Manta’s cached data and the person stays signed in.

Each app keeps its own list of chats, apart from the person’s chats in Manta, so an app never reads conversations held elsewhere. Turn on Share chats with Manta for the app to change that: members then see their Manta chats in the app, and the chats they start in the app appear in Manta. Chats started in the app before that stay in its own list. The app’s own users and visitors always keep its list. Memories are shared either way. Apps with User access made before this setting existed have it on, as their members’ chats were shared then.

Machine access

A server with no person signed in mints its own token:

import { cachedToken, mintWithClientCredentials } from "@manta-eu/auth/web";

const getAccessToken = cachedToken(() =>
  mintWithClientCredentials({ clientId: process.env.MANTA_CLIENT_ID, clientSecret: process.env.MANTA_CLIENT_SECRET }),
);

A machine token acts for no person, so it has no chat and no personal memories: every user it served would share one history. For per-person features, sign people in with User access, or give your own users custom app user tokens.

From any other language, post grant_type=client_credentials with the client ID and secret to <issuer>/protocol/openid-connect/token, with the issuer shown under Connection details.

Custom app users: people without a Manta account

When your app already signs its own users in, it can give each of them their own chat without a Manta account. Your server asks Manta for a token for the person, sending the app’s client ID and secret, and your page uses it like any other access token. Manta keeps the person as an account of your app in its identity provider, which issues the token:

import { customAppUserAccessToken } from "@manta-eu/auth/web";

// On your server, for the person your app has signed in:
const accessToken = await customAppUserAccessToken({
  clientId: process.env.MANTA_CLIENT_ID,
  clientSecret: process.env.MANTA_CLIENT_SECRET,
  userId: user.id,
  name: user.name,
  email: user.email,
  fields: { plan: { value: user.plan, agent: true }, accountNumber: { value: user.accountNumber } },
});
  • Who: userId is your own id for the person: 1 to 128 letters, digits or - _ . @ |. Manta keeps the person under that id inside your app only, so the same id in another app is someone else. They cannot sign in to Manta itself until their organization invites them (see below). Give exactly one of userId and anonymousId, for a visitor (below): Manta refuses a request with both or neither.
  • What it reaches: the person’s own chat, the organization’s shared memories, and reading the datastores your app reaches, as a plain member. The token goes to the browser, so it only reads: it runs no action plan, uploads no file, writes no data, even when the app has SPARQL writes, and keeps no memories. Make changes from your server with the app’s machine token. Pass datastores to narrow a token to some of them; naming one the app does not reach is refused.
  • Who else can read it: everything in the datastores your app reaches, including the memories of any Manta member who chats there, is readable by every custom app user and visitor of your app, with a raw query if they like. Pick datastores for your app that hold only what all of its users may see. An app still on All datastores gets no custom app user tokens.
  • Teams: pass teams, the paths of teams in the app’s organization like /Sales/EU, to place the person in them as a member would be: they then reach the organization’s own datastore and those teams’ datastores. An empty list places them in no team, so they reach only the organization’s own datastore; leaving teams out gives them every datastore your app reaches. Each token carries the teams you sent with it, so a change takes effect on the next one. Naming a team the organization does not have, or a team holding a datastore your app is not limited to, is refused.
  • Profile: the agent sees the name, the email and only the fields marked agent: true, as sent with the latest token. Manta keeps the name, email and teams on the person’s account to list them under Custom apps, and never treats the email as verified. It keeps no other field. The fields marked agent: true ride in every token, so together, names included, they come to at most 4,096 characters.
  • Life: five minutes, with no refresh token. Ask for a new one when it runs out; cachedToken on the page does this for you.
  • Turning it on: any app with Machine access that is limited to chosen datastores can get custom app user tokens. Disabling the app, turning Machine access off, or rotating its secret without keeping the previous one stops new tokens at once; a token already issued works until it runs out.

For a visitor your app has not identified, pass anonymousId instead of userId: a random id you keep for them, such as one in a cookie or in your session. It follows the same rule as userId, 1 to 128 letters, digits or - _ . @ |, so hash a session id that holds any other character. The visitor gets a chat, but no profile, and Manta keeps no memories about them. The same anonymousId brings back that visitor’s chats and a new one starts with none, so keep it private and hard to guess: whoever sends it sees those chats.

To erase a person, for example when they delete their account with you, call the API with the app’s machine token:

await fetch(`${mantaUrl}/agents/custom-app-users/users/${encodeURIComponent(user.id)}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${await machineToken()}` },
});

Manta answers 200 once the person’s chats are erased and their account is deleted, or 404 if it has no such person. A token already issued for them starts no more chats. GET the same URL to see whether Manta has the person: its status is active, or erasing if an earlier DELETE stopped partway, in which case send the DELETE again. A visitor is erased the same way at /agents/custom-app-users/visitors/<anonymousId>; for a few minutes afterwards, Manta refuses new tokens for that visitor id. Someone who accepted an invitation to Manta (below) keeps their account and history: your app only loses its link to them, and the answer’s status is unlinked instead of erased.

Deleting the app stops new tokens at once and removes its custom app users and visitors in the background, about 4,000 an hour: within the hour for most apps, and about a day for one with 100,000 people. Their chats can no longer be opened and are deleted like any chat left unused for 30 days.

The organization’s admins see each app’s users under Custom apps, with Users on the app: their name, email and teams as your app last sent them, when they were added, and how many chats they started. They can remove people there the same way, and Remove by date added removes everyone added in a time window: the cleanup after a leaked secret. Their accounts go at once; their chats are deleted like any chat left unused for 30 days. Rotate the secret first: a token asked for one of them afterwards starts them over as someone new.

Limits:

  • An app may have up to 100,000 custom app users, and may get 6,000 tokens a minute. Past these, Manta refuses with 403 or 429.
  • Each person may send 10 chat messages a minute through the app, and the app 600 in all.
  • Each person may use 10 million model tokens a day through the app, and the app 500 million in all. Fresh input and output count in full and prompt-cache reads at a tenth, as they are priced. A response already under way always finishes; past a daily allowance, the chat says so for the next message until the next day (UTC). If your app needs more, ask us to raise its allowance.

The chat limits count everyone who chats through the app: its own users, its anonymous visitors, and Manta users who signed in through it.

Inviting a custom app user to Manta

An org admin can invite one of your app’s users to Manta from the app’s Users under Custom apps, when your app has sent an email for them, or select several and invite them at once. Manta emails that address; opening the email proves the person holds it, and they set a password. Signing in to Manta with that address, through Google or “Forgot password”, leads to the same step. Their custom app user then becomes a Manta account of your organization, in the teams your app had placed them in when they were invited, or in every team holding one of your app’s datastores when your app left teams out. The invitation keeps that email and those teams for the seven days it lasts, whatever your app sends meanwhile. It is the same account, so their chats from your app are there when they sign in to Manta, and your app’s tokens for them keep working.

Before they continue, Manta tells them what your app keeps: it can still act as them in your organization, and sees their Manta chats there only if Share chats with Manta is on for the app; otherwise your app keeps its own chat list for them from then on. They can disconnect your app at any time from their Manta account menu: your app then gets no new tokens for them, their account and history stay, and a later token request for the same user id starts a new custom app user.

Manta never joins two accounts. If the address already belongs to a Manta account, for example because the person signed up on their own first, the invitation is refused and their history stays with your app; invite that account to your organization from the Team page instead. If the people your app serves will also use Manta, sign them in with User access rather than giving them custom app user tokens: then each of them has one Manta account from the start.

Keep the secret on a server

Manta shows the secret once, when the app is created or its secret is rotated, and does not keep it. Store it in your server’s environment or a secrets manager.

When you rotate the secret on the Custom apps page, the old one keeps working for 24 hours so your servers can switch over, unless you choose to stop it at once, as for a leaked secret. Revoke old secret ends those 24 hours early.

Issuer and discovery

Any OpenID Connect library works. Point it at the realm’s issuer, and it reads the rest from the discovery document:

Deployment Issuer
hiremanta.com https://auth.hiremanta.com/realms/hiremanta-com

The discovery document is at <issuer>/.well-known/openid-configuration, and the token endpoint at <issuer>/protocol/openid-connect/token. The Custom apps page shows your deployment’s issuer and Manta URL under Connection details, and issuerUrl() and discoveryUrl() from @manta-eu/auth/web build them.

Ask for the openid scope, and offline_access if the person should stay signed in across browser sessions. An offline session lasts until it goes unused for 30 days, and at most a year.

Redirect and Home URLs

  • Redirect URLs must match exactly, as typed: no wildcards, no #fragment, no user name or password. Use https, or http for localhost, 127.0.0.1 and [::1].
  • Home URL is where Manta’s sign-in pages send people back to, for example from an expired link, an error or after signing out. Without it those pages are dead ends. A sign-in app requires one; a machine-only app has neither.
  • After signing out, webLogoutUrl may return people to any redirect URL or the home URL, matched exactly as entered.

What a custom app can call

A custom app’s tokens reach a fixed set of routes on your Manta deployment. /sparql, /sparql-update, the upload routes and /agents/chat-ticket also answer under /d/<datastore>, and the vocabulary and shapes routes answer only there; GraphQL takes the datastore from x-manta-datastore or ?datastore= instead.

Route For
/sparql Read-only SPARQL queries
/sparql-update SPARQL updates, once the app may write data (see below)
/graphql Data operations: graph reads, plans, memories, uploads
/agents/uploads/file, /agents/uploads/part Uploading files
/agents/chat-ticket Opening the chat
/api/data/context Horns reading the caller’s access
/d/<datastore>/vocabularies/*, /d/<datastore>/shapes/* manta ontology push and manta shapes push, with a machine token and SPARQL writes

They never reach MCP, or the GraphQL operations for teams, accounts, operators, integrations and billing, nor the custom apps themselves. Pushing rules needs an operator’s own token: the API refuses rules that manta rules push sends with a custom app’s token. Anything new on Manta stays out of reach until it is added to that set.

In the chat, a custom app’s agent has a member’s tools. It cannot write raw facts, change rules, run autopilot, write to another datastore, or schedule work.

Writing with SPARQL updates

An app writes with SPARQL only once an admin ticks SPARQL writes when adding or editing it. Its tokens then carry manta_custom_app_permissions: ["write-data"], the token of a person signed in through the app as well as its machine token. Turning it off takes effect when the app’s current tokens expire. Actions and memories don’t need it: every custom app may run them through GraphQL.

await fetch(`${mantaUrl}/d/${datastoreId}/sparql-update`, {
  method: "POST",
  headers: { Authorization: `Bearer ${await getAccessToken()}`, "Content-Type": "application/sparql-update" },
  body: 'INSERT DATA { :Brief_123 a :Brief ; schema:name "Spring launch" }',
});
  • A write reaches only a datastore the token holds, and only its default graph: GRAPH, WITH and USING answer 403.
  • It takes what every other writer may send: INSERT DATA, DELETE DATA, and DELETE/INSERT with a scoped WHERE. A delete of every triple and graph management such as DROP or LOAD answer 403.
  • Undeclared prefixes are added for you, and : is the deployment’s own vocabulary.
  • The body must be sent as application/sparql-update, or the write answers 415.

Writes to named graphs still go through the shared x-sparql-update-key, which works as before.

Which datastores an app reaches

Name the datastore on every request, with datastoreId in the React providers, the x-manta-datastore header, or /d/<datastore>/.... A datastore the token does not hold answers 403 “This session cannot open that datastore”; a GraphQL mutation that would write it returns that message as a FORBIDDEN error instead.

Every app is limited to the Datastores picked when adding or editing it, and each of its tokens reaches only those:

  • A person signed in through the app reaches the picked ones they hold anyway, the organization’s datastore and the group datastores they belong to. A person outside every picked group signs in but reaches nothing.
  • The app’s own machine token reaches all the picked ones, as a plain member.

A datastore the organization adds later is out of the app’s reach until it is picked too. An app added before every app needed datastores shows All datastores and reaches every datastore of the organization until it is given some. customAppDatastores lists what can be picked; ids like org:…:group:… belong to a group with no datastore and are never offered.

Errors and limits

Response Meaning
401 The token is missing, expired or invalid. Renew it and retry once.
403 Custom apps may not use this The route or GraphQL operation is outside what custom apps can call.
403 This session cannot open that datastore The token does not hold the datastore the request names.
403 This custom app may not write with SPARQL. … The app was not allowed to write; an admin can tick SPARQL writes.
invalid_grant at sign-in The person is not a member of the organization that owns the app.
  • A person who is signed in to Manta, or signs in, with an account outside your organization lands on Manta’s refusal page. Its Sign in with another account asks for another account, which then finishes your app’s sign-in. Manta and other apps open in that browser continue as that account.
  • An organization can have up to 25 custom apps.
  • Access tokens last five minutes. Refresh tokens rotate: an old one is accepted at most once more.
  • Bearer requests are allowed from any origin, without cookies.