Developer docs · Browse & search
EMBED

Full example app

A complete React app on @jwtson/react, with the server that signs people in.

A small web app on @jwtson/react: chat, starting something, answering questions, approvals, stopping, and earlier runs and chats. server.mjs holds the API key and signs people in. signedInUser in it is the one thing to replace with your own sign-in.

To try it on the local stack:

  1. On the console's Developer page, allow http://localhost:4597 as a website and make an operator API key.
  2. Offer an agent or workflow from its Client apps tab.
  3. Build everything with pnpm build from the repository root.
  4. Run it, and open http://localhost:4597:
bash
JWTSON_URL=http://localhost:4310 JWTSON_API_KEY=<your key> JWTSON_ENVIRONMENT=Development pnpm --filter @jwtson/client-example start

node apps/client-example/test/journey.mjs tests it in real Chrome. It needs the local stack with the deterministic model on, and it clears what's offered to client apps in that environment.

The server

It holds the API key and turns its own sign-in into a client session. signedInUser is the one thing to replace with yours.

server.mjs
// The app's own server. It holds the API key, which never reaches the page, and turns its own sign-in into a client session.
//   JWTSON_URL=http://localhost:4310 JWTSON_API_KEY=... JWTSON_ENVIRONMENT=Development node server.mjs
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join, normalize, resolve } from 'node:path';
import { Jwtson } from '@jwtson/sdk';

const baseUrl = process.env.JWTSON_URL ?? 'http://localhost:4310', port = Number(process.env.PORT ?? 4597), root = resolve(import.meta.dirname, 'dist');
const jwtson = new Jwtson({ baseUrl, token: process.env.JWTSON_API_KEY, environment: process.env.JWTSON_ENVIRONMENT ?? 'Development' });
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
// Replace this with your own sign-in. Whoever this returns is who the page acts as.
const signedInUser = () => ({ subject: process.env.EXAMPLE_USER ?? 'example-user', audience: process.env.EXAMPLE_AUDIENCE ?? 'example', ...(process.env.EXAMPLE_ROLE ? { role: process.env.EXAMPLE_ROLE } : {}) });

const sessions = new Set(); let signedOut = false;
createServer(async (request, response) => {
  const path = new URL(request.url, 'http://local').pathname;
  if (path === '/logoff') {
    // Replace this with your own sign-out. It ends the session straight away, and the page finds out on its next request.
    signedOut = true;
    for (const session of sessions) await session.logOff().catch(error => console.error('Could not log off:', error.message));
    sessions.clear();
    response.writeHead(204).end();
    return;
  }
  if (path === '/session') {
    if (request.method !== 'POST') { response.writeHead(405).end(); return; }
    if (signedOut) { response.writeHead(401).end(); return; }
    try {
      const session = await jwtson.clientSessions.create(signedInUser());
      sessions.add(session);
      response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
      response.end(JSON.stringify({ ...session.forPage, targets: session.raw.targets }));
    } catch (error) {
      console.error('Could not sign in:', error.message);
      response.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: error.message }));
    }
    return;
  }
  const wanted = join(root, normalize(path)), file = wanted.startsWith(root) && existsSync(wanted) && statSync(wanted).isFile() ? wanted : join(root, 'index.html');
  response.writeHead(200, { 'content-type': types[extname(file)] ?? 'application/octet-stream' });
  createReadStream(file).pipe(response);
}).listen(port, () => console.log(`http://localhost:${port}`));

Signing the page in

The page asks your server for a session and connects with it. getSecret turns an expired session into a new one while your server still says yes.

src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { connectClient } from '@jwtson/sdk';
import { JwtsonProvider } from '@jwtson/react';
import { App } from './App.js';

const signIn = async (): Promise<{ secret: string; baseUrl: string; targets: string[] }> => {
  const reply = await fetch('/session', { method: 'POST' });
  if (!reply.ok) throw new Error('This app could not sign you in.');
  return reply.json() as Promise<{ secret: string; baseUrl: string; targets: string[] }>;
};
const root = createRoot(document.getElementById('root')!);
try {
  const session = await signIn();
  // `getSecret` means an expired session quietly becomes a new one, as long as your server still says yes.
  const client = connectClient({ ...session, getSecret: async () => (await signIn().catch(() => null))?.secret ?? null });
  root.render(<StrictMode><JwtsonProvider client={client}><App targets={session.targets} /></JwtsonProvider></StrictMode>);
} catch (failure) {
  root.render(<p role="alert">{(failure as Error).message}</p>);
}

The app

Chat, starting something, answering questions, approvals, stopping, and earlier runs and chats, with what each role can do.

src/App.tsx
import { useState, type FormEvent } from 'react';
import { PendingRequests, ResultFiles, RunProgress, useChat, useChats, useClientSession, useRun, useRuns, useStart } from '@jwtson/react';
import type { Run } from '@jwtson/sdk';

function Chat({ target, group, canStart }: { target: string; group: string | undefined; canStart: boolean }): React.JSX.Element {
  const [chosen, setChosen] = useState<string | null>(null), [editing, setEditing] = useState<{ sequence: number; text: string } | null>(null), [problem, setProblem] = useState('');
  const chats = useChats(group ? { group } : {}), chat = useChat(target, { id: chosen, ...(group ? { group } : {}), onBranch: id => { setChosen(id); chats.refresh(); } }), [text, setText] = useState('');
  const submit = (event: FormEvent) => {
    event.preventDefault();
    if (!text.trim()) return;
    void chat.send(text).then(chats.refresh);
    setText('');
  };
  const writing = chat.current.run && !chat.current.finished, latest = chat.messages.at(-1);
  // Read out once it's finished, not word by word while it's written, and only for what was sent from this page.
  const announced = latest?.answer && latest.run?.id === chat.current.run?.id ? latest.answer : '';
  return (
    <section aria-label="Chat">
      <ol className="messages">
        {chat.messages.map(message => (
          <li key={message.id}>
            {editing && editing.sequence === message.sequence ? (
              <form className="row" onSubmit={event => { event.preventDefault(); if (!editing.text.trim()) return; void chat.edit(editing.sequence, editing.text).then(chats.refresh); setEditing(null); }}>
                <label htmlFor={`edit-${message.id}`}>Say instead</label>
                <input id={`edit-${message.id}`} value={editing.text} onChange={event => setEditing({ ...editing, text: event.target.value })} autoComplete="off" />
                <button type="submit">Send instead</button>
                <button type="button" onClick={() => setEditing(null)}>Cancel</button>
              </form>
            ) : <p className="said">{message.said}</p>}
            {canStart && message.sequence !== null && !writing && !editing && <button type="button" aria-label={`Edit: ${message.said.slice(0, 60)}`} onClick={() => setEditing({ sequence: message.sequence!, text: message.said })}>Edit</button>}
            <p className="answer" data-status={message.status}>{message.answer ?? (message.run?.id === chat.current.run?.id ? chat.current.text : '') ?? ''}</p>
          </li>
        ))}
      </ol>
      <p role="status" className="visually-hidden">{announced}</p>
      <PendingRequests items={chat.current.pending} />
      {canStart && writing && chat.current.canCancel && <button type="button" onClick={() => void chat.current.cancel()}>Stop</button>}
      {canStart && chat.current.status === 'canceled' && <button type="button" onClick={() => { setProblem(''); void chat.current.resume().catch((failure: Error) => setProblem(failure.message)); }}>Continue</button>}
      {problem && <p role="alert">{problem}</p>}
      {chat.error && <p role="alert">{chat.error.message}</p>}
      {canStart && <form onSubmit={submit} className="row">
        <label htmlFor="message">Message</label>
        <input id="message" value={text} onChange={event => setText(event.target.value)} autoComplete="off" />
        <button type="submit" disabled={chat.sending || Boolean(writing)}>Send</button>
      </form>}
      <h2>Earlier chats</h2>
      {chats.error && <p role="alert">{chats.error.message}</p>}
      {chats.items?.length ? (
        <ul>{chats.items.map(item => <li key={item.id}><button type="button" onClick={() => setChosen(item.id)}>Chat {item.id?.slice(-6)}{item.branchedFrom ? `, edited from ${item.branchedFrom.sessionId.slice(-6)}` : ''}</button></li>)}</ul>
      ) : <p>{chats.items ? 'None yet.' : 'Loading…'}</p>}
    </section>
  );
}
function RunView({ run, onChange, canStart }: { run: Run | string; onChange?: () => void; canStart: boolean }): React.JSX.Element {
  const live = useRun(run), [problem, setProblem] = useState('');
  return (
    <div aria-label="Run" role="group">
      <RunProgress run={live} />
      {live.text && <p data-testid="text">{live.text}</p>}
      <PendingRequests items={live.pending} {...(onChange ? { onDone: onChange } : {})} />
      <ResultFiles files={live.result?.files} />
      {live.canCancel && <button type="button" onClick={() => void live.cancel()}>Stop</button>}
      {canStart && live.status === 'canceled' && <button type="button" onClick={() => { setProblem(''); void live.resume().then(onChange, (failure: Error) => setProblem(failure.message)); }}>Continue</button>}
      {problem && <p role="alert">{problem}</p>}
      {live.events.length > 0 && <details><summary>{live.events.length} steps</summary><ul>{live.events.map(event => <li key={event.sequence}>{event.item.kind}</li>)}</ul></details>}
    </div>
  );
}
// JSON if it is, so this works for agents that take text and workflows that take objects.
const asInput = (text: string): unknown => { try { return JSON.parse(text); } catch { return text; } };
function Task({ target, group, canStart }: { target: string; group: string | undefined; canStart: boolean }): React.JSX.Element {
  const scope = group ? { group } : {}, started = useStart(target, scope), runs = useRuns(scope), [text, setText] = useState(''), [opened, setOpened] = useState<string | null>(null);
  const submit = (event: FormEvent) => { event.preventDefault(); void started.start(asInput(text)).then(() => runs.refresh()); };
  return (
    <section aria-label="Task">
      {canStart && <form onSubmit={submit} className="row">
        <label htmlFor="task">Input</label>
        <input id="task" value={text} onChange={event => setText(event.target.value)} autoComplete="off" />
        <button type="submit" disabled={started.starting}>{started.starting ? 'Starting…' : 'Start'}</button>
      </form>}
      {started.error && <p role="alert">{started.error.message}</p>}
      {started.run && <RunView run={started.run} onChange={runs.refresh} canStart={canStart} />}
      <h2>Your runs</h2>
      {runs.error && <p role="alert">{runs.error.message}</p>}
      {runs.items?.length ? (
        <ul>{runs.items.slice(0, 20).map(item => <li key={item.id}><button type="button" onClick={() => setOpened(item.id)}>{item.status ?? 'run'} · {item.id.slice(-6)}</button></li>)}</ul>
      ) : <p>{runs.items ? 'None yet.' : 'Loading…'}</p>}
      {opened && <RunView key={opened} run={opened} canStart={canStart} />}
    </section>
  );
}
export function App({ targets }: { targets: readonly string[] }): React.JSX.Element {
  const me = useClientSession(), canStart = me?.role === 'operator', [target, setTarget] = useState(targets[0] ?? ''), [mode, setMode] = useState<'chat' | 'task'>('chat'), [group, setGroup] = useState('');
  if (!target) return <main><h1>Client example</h1><p>Nothing is offered to client apps yet. Offer an agent or workflow from its Client apps tab.</p></main>;
  return (
    <main>
      <h1>Client example</h1>
      <div className="row">
        <label htmlFor="target">Talk to</label>
        <select id="target" value={target} onChange={event => setTarget(event.target.value)}>{targets.map(name => <option key={name}>{name}</option>)}</select>
        <label htmlFor="group">Shared group (optional)</label>
        <input id="group" value={group} onChange={event => setGroup(event.target.value.trim())} autoComplete="off" />
        <button type="button" aria-pressed={mode === 'chat'} onClick={() => setMode('chat')}>Chat</button>
        <button type="button" aria-pressed={mode === 'task'} onClick={() => setMode('task')}>Task</button>
        <button type="button" onClick={() => void fetch('/logoff', { method: 'POST' })}>Sign out</button>
      </div>
      {me && !canStart && <p>You can follow and review runs here, but not start them.</p>}
      {mode === 'chat' ? <Chat key={`${target}|${group}`} target={target} group={group || undefined} canStart={canStart} /> : <Task key={`${target}|${group}`} target={target} group={group || undefined} canStart={canStart} />}
    </main>
  );
}

From the apps/client-example README, as the code stands.