Skip to content

Build your own UI

ONE ORIGIN Sign-in /auth/login Your UI /app/ IdentityFlow /graphql sets cookie POST with cookie
ONE ORIGIN Sign-in /auth/login Your UI /app/ IdentityFlow /graphql sets cookie POST with cookie
The IdentityFlow sign-in sets the session cookie; your UI on the same origin then calls /graphql with it, and IdentityFlow checks every call as the signed-in account.

This page shows how a UI of your own calls the IdentityFlow 0.3.1 GraphQL API. It lists the dialogs waiting for the signed-in account, shows and answers one, and starts a workflow. Every query root, mutation and error code is listed in the GraphQL API reference.

Decide first: 0.3.1 accepts only the browser session from its own sign-in at /graphql, with no token or API key, and is built for a UI on the same origin as IdentityFlow, behind the same reverse proxy. A backend service or script without a browser session cannot call the API. Where your UI must run gives the details.

First step: serve your UI under a free path such as /app/, then send a request.

Release archives include identity-flow-vX.Y.Z.graphql beside the IdentityFlow program in your release archive. Use it to generate client types and validate queries. Match X.Y.Z to your server version. The file describes the served API, including custom scalars. Production introspection is disabled; the GraphQL API reference explains the fields and errors.

IdentityFlow 0.3.1 accepts one kind of credential at /graphql: the identity_flow_session cookie that its OIDC sign-in sets in the browser. 0.3.1 has no token or API key for backend services or scripts that run without a browser session.

0.3.1 is built for a UI served from the same origin as IdentityFlow, the origin of auth.oidc.redirectUri in the configuration. If your UI is a separate web server, put it behind the same reverse proxy as IdentityFlow, under a path prefix IdentityFlow does not use, such as /app/. IdentityFlow uses / (which redirects to /tasks), /tasks, /my, /instances, /workflows, /access, /analytics, /dead-letters, /auth/, /graphql, /-hooks/, /_app/, /favicon.ico and /favicon.png.

Everything served from that origin acts with the signed-in account’s authority. The cookie’s path is /, so your UI’s server receives the session cookie with every request, and any script on the origin can call the API. Serve only code you trust there, and do not log request cookies.

ONE ORIGIN Browser sends cookie Reverse proxy routes by path Your UI server receives cookie IdentityFlow checks cookie requests routes /app/ routes /graphql
ONE ORIGIN Browser sends cookie Reverse proxy routes by path Your UI server receives cookie IdentityFlow checks cookie requests routes /app/ routes /graphql
Behind one reverse proxy, your UI and IdentityFlow share one origin, so the browser sends the session cookie to both.
Cookie and cross-origin behavior

The cookie is HttpOnly and SameSite=Lax: your code never reads it, and browsers do not send it with requests from other sites. They do send it with requests from pages on the same site, such as another subdomain of the same domain or another port on the same host. At /graphql, IdentityFlow 0.3.1 returns 405 to OPTIONS requests and sends no Access-Control-Allow-* headers to other origins. SvelteKit rejects cross-origin form, multipart and text/plain POSTs with 403. CORS controls which browser pages can read responses; it is not server-side authorization. Serve your UI from the same origin as IdentityFlow.

To sign someone in, send the browser to /auth/login. Add ?returnTo=/your/path to come back to a page of your UI afterwards; the path must be on the same origin. A session lasts auth.oidc.sessionTtl, at most 8 hours, and is not extended. After that every call fails with UNAUTHENTICATED, and your UI sends the browser to /auth/login again. To sign out, post a same-origin form to /auth/logout.

The account that signed in is the actor for every call. IdentityFlow checks its access on each read and each command; your UI does not enforce access rules itself.

All examples on this page use this helper. It posts the query and its variables to /graphql and turns an entry in errors[] into an exception with the error code.

export class GraphQLRequestError extends Error {
readonly code: string;
constructor(message: string, code: string) {
super(message);
this.name = 'GraphQLRequestError';
this.code = code;
}
}
interface GraphQLResponse<Data> {
readonly data?: Data | null;
readonly errors?: readonly {
readonly message: string;
readonly extensions?: { readonly code?: string };
}[];
}
export async function graphql<Data>(
query: string,
variables: Record<string, unknown> = {},
): Promise<Data> {
const response = await fetch('/graphql', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
// IdentityFlow answers this request with HTTP 200, errors included.
// Another status means the request itself was malformed or never reached it.
if (!response.ok) {
throw new GraphQLRequestError(`HTTP ${response.status}`, 'HTTP_ERROR');
}
const body = (await response.json()) as GraphQLResponse<Data>;
const [error] = body.errors ?? [];
if (error) {
throw new GraphQLRequestError(error.message, error.extensions?.code ?? 'UNKNOWN');
}
if (!body.data) {
throw new GraphQLRequestError('The response has no data', 'UNKNOWN');
}
return body.data;
}

For Go developers: async function … : Promise<Data> returns a value you await, much like a call that blocks until the result arrives. <Data> is a type parameter, like a Go generic.

IdentityFlow answers a request like this one with HTTP 200, errors included. The error sits in errors[], and data is null when a field the response needs failed, so check errors[] on every response. A body that is not valid JSON gets 400; the reference lists the other cases.

assignedDialogs lists the pending dialogs the signed-in account may answer: those whose assignees include one of the account’s principals, the { providerId, subject } pairs its identity provider reports for it.

export const assignedDialogsQuery = /* GraphQL */ `
query AssignedDialogs($after: String) {
assignedDialogs {
list(first: 100, after: $after) {
items {
id
instanceId
workflowName
name
message
dueAt
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
export interface DialogSummary {
readonly id: string;
readonly instanceId: string;
readonly workflowName: string;
readonly name: string;
readonly message: string | null;
readonly dueAt: string | null;
readonly createdAt: string;
}
interface DialogPage {
readonly items: DialogSummary[];
readonly pageInfo: { readonly hasNextPage: boolean; readonly endCursor: string | null };
}
export async function listAssignedDialogs(): Promise<readonly DialogSummary[]> {
const dialogs: DialogSummary[] = [];
let after: string | null = null;
do {
const data: { assignedDialogs: { list: DialogPage } } = await graphql(assignedDialogsQuery, {
after,
});
const page = data.assignedDialogs.list;
dialogs.push(...page.items);
after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (after);
return dialogs;
}
list(first: 100, after: $after) {
  1. page size, max 100
  2. previous endCursor

A list returns one page of at most 100 records. listAssignedDialogs asks for pageInfo with the items and passes endCursor as after for the next page until hasNextPage is false. Lists have no total count; see List arguments.

assignedDialogs.find returns one dialog with its taskContext, the data the dialog carries for the person who answers it.

export const dialogQuery = /* GraphQL */ `
query Dialog($id: ID!) {
assignedDialogs {
find(id: $id) {
id
instanceId
workflowName
name
message
dueAt
taskContext
}
}
}
`;
export interface DialogDetail extends Omit<DialogSummary, 'createdAt'> {
readonly taskContext: unknown;
}
export async function findDialog(id: string): Promise<DialogDetail> {
const data = await graphql<{ assignedDialogs: { find: DialogDetail } }>(dialogQuery, { id });
return data.assignedDialogs.find;
}

Once someone has answered the dialog, it is no longer pending, and find returns NOT_FOUND. Remove it from your list and reload.

completeDialog answers the dialog with status: COMPLETED and the answer in data.

export const completeDialogMutation = /* GraphQL */ `
mutation CompleteDialog($input: CompleteDialogInput!) {
completeDialog(input: $input) {
dialogId
instanceId
status
decidedAt
}
}
`;
export interface DialogCompletion {
readonly dialogId: string;
readonly instanceId: string;
readonly status: 'COMPLETED' | 'ERRORED';
readonly decidedAt: string;
}
/** Validate `answer` against the dialog's schema before you call this. */
export async function answerDialog(dialogId: string, answer: unknown): Promise<DialogCompletion> {
const data = await graphql<{ completeDialog: DialogCompletion }>(completeDialogMutation, {
input: { dialogId, status: 'COMPLETED', data: answer },
});
return data.completeDialog;
}

IdentityFlow checks that the account holds one of the dialog’s assignee principals at the moment it submits, that the dialog is still pending, and that the instance, the run of the workflow, is active or paused. It asks the account’s provider, the identity hook that reports its principals, for the current principals on every command, so a change in your identity system takes effect on the next answer. If the provider does not answer, the command is refused.

Show a refused answer next to its fields. IdentityFlow checks the answer against the dialog’s schema before it records it, custom checks included. An answer that does not match is refused with BAD_USER_INPUT, and extensions.issues lists one { path, message } per rejected field, with the path relative to the answer. Nothing is recorded and the dialog keeps waiting, so the person can correct the answer and submit it again. Query DialogDetail.responseContract for the JSON Schema description of the answer to build your form and check input before submitting. It is null when no contract was declared or none could be derived; this does not mean the dialog accepts any answer. The Engine still validates every completion against the dialog’s own schema, including custom checks.

completeDialog status COMPLETED Schema check dialog schema Answer recorded returns COMPLETED BAD_USER_INPUT nothing recorded Dialog waits PENDING checks valid invalid leaves
completeDialog status COMPLETED Schema check dialog schema Answer recorded returns COMPLETED BAD_USER_INPUT nothing recorded Dialog waits PENDING checks valid invalid leaves
completeDialog checks the answer against the dialog’s schema before it records it; a refused answer leaves the dialog waiting.

status: ERRORED is the other outcome. Pass the error in data: the dialog step fails with it, and the step’s retry policy decides what happens next.

Both outcomes go through the same input:

input: { dialogId, status: 'COMPLETED', data: answer },
  1. ERRORED fails the step
  2. checked before recorded

startCatalog lists the Workflow Definitions the account may start: the newest version of each workflow name and release channel. Pass versions: ALL to list older versions too; each version has its own id, and a start with that id begins the instance on that version. Like every instance, it then moves to compatible versions of its release channel as it continues. find adds inputContract, a JSON Schema description of the parameters derived from the definition’s schema; Build a start form from the input schema shows how to write a schema that makes a good form. inputContract is null when the definition has no schema or its schema is not a Valibot schema.

export const startCatalogQuery = /* GraphQL */ `
query StartCatalog {
startCatalog {
list(first: 100) {
items {
id
name
version
releaseChannel
label
}
}
}
}
`;
export const startableWorkflowQuery = /* GraphQL */ `
query StartableWorkflow($id: ID!) {
startCatalog {
find(id: $id) {
id
name
version
description
inputContract
}
}
}
`;

Pass the id of a catalog item as workflow to startWorkflow:

export const startWorkflowMutation = /* GraphQL */ `
mutation StartWorkflow($input: StartWorkflowInput!) {
startWorkflow(input: $input) {
id
workflowName
workflowVersion
status
createdAt
}
}
`;
export interface StartedInstance {
readonly id: string;
readonly workflowName: string;
readonly workflowVersion: string;
readonly status: 'ACTIVE' | 'PAUSED' | 'TERMINATED' | 'COMPLETED' | 'ERRORED';
readonly createdAt: string;
}
/** `workflowId` is the `id` of an item from the start catalog. */
export async function startWorkflow(workflowId: string, params: unknown): Promise<StartedInstance> {
const data = await graphql<{ startWorkflow: StartedInstance }>(startWorkflowMutation, {
input: { workflow: workflowId, params },
});
return data.startWorkflow;
}
input: { workflow: workflowId, params },
  1. catalog item id
  2. checked before start

IdentityFlow checks params against the definition’s schema before it creates the instance. Parameters that do not match are refused, and no instance is created.

Reads return only what the signed-in account may see. The access check runs inside the database query, before sorting and paging, so a page contains only records the account may see, and first: 100 returns up to 100 of them. A view the account has no access to returns an empty list, not an error.

find returns NOT_FOUND with the message Resource not found both for a record that does not exist and for one the account may not see. Your UI cannot tell the two apart.

The read views table in the reference lists the access behind each view, and the authorization boundary describes how reads and commands are checked.

An unauthenticated call, for example after the session expired, returns:

{
"errors": [
{
"message": "Authentication required",
"locations": [{ "line": 1, "column": 3 }],
"path": ["assignedDialogs"],
"extensions": { "code": "UNAUTHENTICATED" }
}
],
"data": null
}

A refused command returns an error code that says what your UI can do next. This response came from completeDialog sent by an account that is not an assignee:

{
"errors": [
{
"message": "Resource not found",
"locations": [{ "line": 1, "column": 42 }],
"path": ["completeDialog"],
"extensions": { "code": "NOT_FOUND" }
}
],
"data": null
}

An account that may not act on a dialog gets the same NOT_FOUND as for a dialog that does not exist, so the response does not reveal that the dialog exists. FORBIDDEN comes only from pauseInstance, resumeInstance and terminateInstance on an instance the account can see. The errors table in the reference lists every code and message.

extensions.codeWhat your UI does
UNAUTHENTICATEDSend the browser to /auth/login?returnTo=….
NOT_FOUNDTreat the record as gone; reload the list.
FORBIDDENShow that the account may not operate this instance.
BAD_USER_INPUTFix the request. An argument is malformed, such as an ID or first above 100, or Input was rejected, such as a dialog answer that fails its schema: show each extensions.issues message next to the field its path names.
CONFLICTReload the record; it changed or is no longer in a state the command accepts.
LOCKEDRetry after a short wait.
INTERNAL_SERVER_ERRORReload the record and show that the action failed. An operator finds the reason in the log.
GRAPHQL_VALIDATION_FAILED, GRAPHQL_PARSE_FAILED, or no codeFix the query. It does not match the schema, is not valid GraphQL, or exceeds a query limit.
  • 0.3.1 has no credential for clients without a browser session.
  • 0.3.1 has no subscriptions; reload the list to see new dialogs.
  • 0.3.1 has no REST API.