Your harness serves many users. Ada must not read the threads of Bob, and the company Acme must never see a thread of the company Globex. Give the host access. Then the host reads the user of each request, asks you about each action, and keeps each organization apart.
A user here is { id, name?, tenantId? }: who a call acts for. Its type is HarnessUser.
Give createHarnessHost an access object and a sessions store. The store keeps the owner and the members of each session:
import { createHarnessHost, defineHarness, membersOnly } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
export const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
// Your own login check: a session token to a user.
const users = new Map([
['Bearer token-ada', { id: 'ada', tenantId: 'acme' }],
['Bearer token-gus', { id: 'gus', tenantId: 'globex' }],
])
export const host = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) => users.get(request.headers.get('authorization') ?? '') ?? null,
rules: membersOnly(),
},
})
export const handler = createHarnessHandler({ host, harness: assistant })The handler gets the user from getUser, so it needs no login option of its own. A new thread belongs to the user who opens it first.
Give access your own rules with an isAllowed function. isAllowed gets the user, the action, and the session. Return false to refuse. The user has the type that getUser returns, so your own fields are typed too:
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
interface User {
id: string
tenantId: string
isAdmin: boolean
}
const users = new Map<string, User>([
['Bearer token-ada', { id: 'ada', tenantId: 'acme', isAdmin: false }],
['Bearer token-eve', { id: 'eve', tenantId: 'acme', isAdmin: true }],
])
export const host = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) => users.get(request.headers.get('authorization') ?? '') ?? null,
rules: { isAllowed: ({ user, action, session }) => {
const isOwner = session.members.some(
(member) => member.userId === user.id && member.role === 'owner',
)
if (isOwner || user.isAdmin) return true
// Everyone in the organization can read every thread of it.
return action === 'read' || action === 'list'
} },
},
})Each action covers these calls:
| Action | What it covers |
|---|---|
| read | Open the session, and read its stream, snapshot, transcript, media, and child runs. |
| list | See the session in host.sessions.list. |
| send | Prompt, steer, follow up, run a command or a tool, start an agent, and upload media. |
| answer | Answer a question or an approval. |
| operator | Reply as an operator with send(text, { as: 'operator' }), and answer a human wait of any run. See Hand off to a person. |
| cancel | Stop a run, or drop an input that waits. |
| configure | Change the thread settings, the plugin config, the title, and the metadata. |
| share | Add or remove members. |
| delete | Remove the session from the index. |
| erase | Erase the messages of one user with host.sessions.eraseUser. See Erase the messages of one person. |
| fork | Copy the session into a new session. |
| transfer | Move the conversation to another thread, also of another harness, with host.transfer. See Move a conversation to another harness. |
The rules have two more keys. membersOnly() sets both. Your own rules can leave them out:
isAllowed decides only the rule of your app. The host does these checks before it calls it:
A refused action throws AccessDeniedError, with code: 'forbidden', the action, and the threadId. The handler answers it with 403.
The host keeps only the id and the tenantId of a user in the log. So isAllowed can get a user with only these two fields. A rule that reads another field, such as isAdmin above, must refuse when the field is missing.
A signed media URL is no login. The handler runs getUser for it too, and checks read for that user, in the tenant of the URL. A request with no user gets 401. So load the file with a request that sends the login, for example an <img> on the same site, which sends its cookies.
An <img> on another site sends no cookies, so getUser finds no user. Give access a getGuestUser function. It returns a guest for that request, and isAllowed decides read for the guest:
import { createHarnessHost, membersOnly } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
interface User {
id: string
tenantId?: string
isGuest?: boolean
}
const members = membersOnly()
export const host = createHarnessHost<User>({
persistence: memoryPersistence(),
access: {
getUser: () => null,
// `tenantId` is the tenant of the signed URL.
getGuestUser: ({ tenantId }) => ({ id: 'guest', tenantId, isGuest: true }),
rules: {
isAllowed: (input) =>
input.user.isGuest === true ? input.action === 'read' : members.isAllowed(input),
filterSessionList: members.filterSessionList,
members: members.members,
},
},
})Give each user a tenantId in getUser. The tenant keeps each organization apart:
A cron job, an admin tool, or a queue worker has no request, so getUser cannot run. Pass the user yourself. The host checks the action for that user, the same as for a request:
// Your database knows the owner of each thread.
export async function nightlyReport(threadId: string, owner: { id: string; tenantId: string }) {
const session = await host.open(assistant, { threadId, user: owner })
await session.send('Write the report of today.')
}Your own routes and an MCP server have a request. Read its user with host.getUserOfRequest:
export async function myThreads(request: Request) {
const user = await host.getUserOfRequest({ request })
if (!user) return new Response('Unauthorized', { status: 401 })
const page = await host.sessions.list({ user, limit: 20 })
return Response.json(page.entries.map((entry) => entry.title ?? 'New chat'))
}host.getUserOfRequest throws on a host with no access.
Your app already keeps who can use each thread, for example a table of teams. Read it in isAllowed. When you take a user out of the table, call host.revoke:
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
const users = new Map([
['Bearer token-ada', { id: 'ada', tenantId: 'acme' }],
['Bearer token-bob', { id: 'bob', tenantId: 'acme' }],
])
// Your own table: the team of each thread.
const teams = new Map([['plan-q3', new Set(['ada', 'bob'])]])
export const host = createHarnessHost({
persistence: memoryPersistence(),
access: {
getUser: ({ request }) => users.get(request.headers.get('authorization') ?? '') ?? null,
rules: { isAllowed: ({ user, session }) =>
session.members.some((member) => member.userId === user.id) ||
(teams.get(session.threadId)?.has(user.id) ?? false) },
},
})
export async function removeFromTeam(threadId: string, user: { id: string; tenantId: string }) {
teams.get(threadId)?.delete(user.id)
await host.revoke({ threadId, user })
}When the promise of host.revoke resolves:
When the cancels cannot be sent (for example, the log store fails), the promise rejects. The access is gone already. Call host.revoke again to stop the work. host.sessions.unshare rejects the same way, after it removed the member.
host.sessions.share and unshare need members in the rules, which membersOnly() has. With your own tables, change the tables, then call host.revoke.
A demo, or a tool on your own machine, has no login. Give the handler openAccess on a host with no access:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({ persistence: memoryPersistence() })
// One user on this machine: every request runs as Ada.
export const handler = createHarnessHandler({
host,
harness: assistant,
openAccess: { user: { id: 'ada' } },
})CAUTION: Do not use openAccess on a server that the internet can reach. Every caller can then use every thread.
With access, a keyed adapter and run.keys read only the keys that each user saved. They do not read the env vars of the server, so a user never spends the key of the server. To let users fall back to the server key, set allowServerEnvKeys: true in access, next to getUser and policy. See Connect model providers.