Harness

Code language

View this guide and its examples in your language.

Prism Harness

Durable agent sessions for PHP, TypeScript and Python — threads, reconstructed session state, persisted provider/model/mode selection, exclusive locking, human approvals, and a bounded agent runtime with nested subagents that carry their own budgets.

When to use Harness#

Use Harness for agents that need persistent conversations, configurable modes, tool execution and human approvals across requests. For individual model requests without session state, use Prism core directly.

Installation#

Choose the package for your runtime. PHP integrates with Laravel; TypeScript and Python run independently of any framework.

composer require particle-academy/prism-harness
php artisan migrate
npm install @particle-academy/prism-harness
pip install prism-ai-harness

Runtime requirements:

Package Requires
particle-academy/prism-harness PHP 8.2+, Laravel ^12.69 or ^13.30, particle-academy/prism 0.135 or newer
@particle-academy/prism-harness Node.js 22+, no runtime dependencies
prism-ai-harness Python 3.10+, no runtime dependencies

In PHP, the migrations are loaded for you. Running migrate creates the harness_threads, harness_thread_messages and harness_session_state tables. Publish the config file when you need to change it, and the migrations only if you need to change the schema:

php artisan vendor:publish --tag=prism-harness-config
php artisan vendor:publish --tag=prism-harness-migrations

The config file is config/prism-harness.php. A published config/harness.php from an older release is refused at boot with a message telling you to rename it, rather than being silently ignored.

Setting up#

PHP builds everything from config and the Laravel container. TypeScript and Python take their pieces in constructors: a PrismHarness for state, a ModeRegistry and ToolRegistry for what a run may do, and an AgentRuntime that drives your model through an LlmClient function you supply.

use Prism\Harness\Facades\PrismHarness;
use Prism\Harness\Tools\ToolRegistry;
use Prism\Prism\Facades\Tool;

// In a service provider: make a tool available to modes that list it.
app(ToolRegistry::class)->register(
    Tool::as('lookup_order')
        ->for('Look up an order by its id.')
        ->withStringParameter('id', 'The order id')
        ->using(fn (string $id): string => json_encode(Order::findOrFail($id)->only('id', 'status')))
);

// Anywhere: resolve a session through the facade, or inject Prism\Harness\PrismHarness.
$session = PrismHarness::for($user)->session('support');
import {
  AgentRuntime,
  FileSessionStore,
  MemorySessionStore,
  ModeRegistry,
  PrismHarness,
  ToolRegistry,
  type LlmClient,
} from '@particle-academy/prism-harness';

const harness = new PrismHarness({
  drivers: {
    memory: () => new MemorySessionStore(),
    files: () => new FileSessionStore('./storage/harness'),
  },
  stores: { ephemeral: 'memory', durable: 'files' },
});

const modes = new ModeRegistry({
  default: 'support',
  modes: {
    support: { system_prompt: 'You help customers with their orders.', tools: ['lookup_order'], max_steps: 8 },
  },
});

const tools = new ToolRegistry().register({
  name: 'lookup_order',
  description: 'Look up an order by its id.',
  handle: async (args) => JSON.stringify(await orders.find(String(args.id))),
});

// Your model call. It receives the system prompt, the thread as messages, the
// offered tools, provider, model and provider options, and returns one step.
const client: LlmClient = async (request) => {
  const reply = await callYourModel(request);
  return { text: reply.text, finishReason: reply.finishReason, toolCalls: reply.toolCalls, costUsd: reply.costUsd };
};

const runtime = new AgentRuntime({ client, modes, tools });

const session = harness.for({ type: 'User', id: user.id }).session('support');
import json

from prism_harness import (
    AgentRuntime,
    FileSessionStore,
    LlmRequest,
    LlmResponse,
    MemorySessionStore,
    ModeRegistry,
    Participant,
    PrismHarness,
    ToolRegistry,
)

harness = PrismHarness(
    drivers={
        "memory": MemorySessionStore,
        "files": lambda: FileSessionStore("./storage/harness"),
    },
    stores={"ephemeral": "memory", "durable": "files"},
)

modes = ModeRegistry({
    "default": "support",
    "modes": {
        "support": {
            "system_prompt": "You help customers with their orders.",
            "tools": ["lookup_order"],
            "max_steps": 8,
        }
    },
})


class LookupOrder:
    name = "lookup_order"

    def handle(self, args):
        return json.dumps(orders.find(str(args["id"])))


tools = ToolRegistry().register(LookupOrder())


# Your model call. It receives the system prompt, the thread as messages, the
# offered tools, provider, model and provider options, and returns one step.
def client(request: LlmRequest) -> LlmResponse:
    reply = call_your_model(request)
    return LlmResponse(text=reply.text, finish_reason=reply.finish_reason, cost_usd=reply.cost_usd)


runtime = AgentRuntime(client=client, modes=modes, tools=tools)

session = harness.for_(Participant("User", user.id)).session("support")

In TypeScript and Python the runtime calls your client once per step. It passes the mode's providerOptions unchanged and records whatever the client returns. A tool there is any object with a name and a handle(args) method; describe its parameters to your provider inside the client. Return costUsd / cost_usd when you know it, because cost budgets need it.

Threads#

A thread is a stored conversation, addressed by participant and scope. The Harness reads its history for a run and records the resulting exchange, including tool calls, tool results and approval decisions.

use Prism\Harness\Models\Thread;
use Prism\Prism\Facades\Prism;

$thread = Thread::forParticipant($user, 'support');

$response = Prism::text()
    ->using('anthropic', 'claude-sonnet-4-5')
    ->withThread($thread)               // everything said so far
    ->withPrompt('And after that?')     // the turn being taken now
    ->asText();

$thread->record($response->messages);   // the full exchange, tool steps included
const response = await runtime.send(session, 'And after that?');

// runtime.send records the full exchange, including tool steps.
const messages = await session.thread().messages();
response = runtime.send(session, "And after that?")

# runtime.send records the full exchange, including tool steps.
messages = session.thread().messages()

PHP's Eloquent thread also implements Prism's Thread contract, so it can be passed straight to withThread(). When you call Prism directly like this, record the returned messages yourself; the Harness runtime records them for you when it sends a turn.

The Harness owns its stored message format, separate from provider wire formats. In PHP, content parts are stored with their concrete class, so an image comes back an image, and content that cannot be stored without losing information throws UnmappableContent at write time.

Thread rows are trusted storage

In PHP, rebuilding an attachment can resolve a recorded locator, so a row carrying a local_path or a url becomes a file read or an outbound fetch at replay time. Never let request input write directly into harness_thread_messages. The runtimes in all three languages refuse attachments built from a URL or a path when a turn is sent; provide embedded bytes, provider file ids or document chunks.

See Sessions and threads for starting a new conversation, reading state and locking.

Sessions#

A session is one participant's live runtime: the active mode, provider and model, and the thread it is bound to. It is resolved per request from the store, so any worker can continue it.

$session = PrismHarness::for($user)->session('support');

$session->usingProvider('anthropic')->usingModel('claude-sonnet-4-5')->usingMode('support');
await session.usingProvider('anthropic');
await session.usingModel('claude-sonnet-4-5');
await session.usingMode('support');
session.using_provider("anthropic").using_model("claude-sonnet-4-5").using_mode("support")

Running a turn#

send() resolves the session's mode, provider, model, system prompt and tool allowlist, runs the model with the mode's step limit, records every message to the thread, and leaves run bookkeeping on success and on failure.

$result = $session->send('Help with my order');

echo $result->text();
$result->runId;              // the run that produced it
$result->awaitingApproval(); // true when a tool is waiting for a person
$session->run();             // id, status, mode, provider, model, finish_reason, tool_calls
const result = await runtime.send(session, 'Help with my order');

console.log(result.text);
result.runId;            // the run that produced it
result.finishReason;     // 'awaiting_approval' when a tool is waiting for a person
result.stoppedBecause;   // why a budget stopped the run, or null
await session.run();     // id, status, mode, provider, model, finish_reason, tool_calls
result = runtime.send(session, "Help with my order")

print(result.text)
result.run_id           # the run that produced it
result.finish_reason    # "awaiting_approval" when a tool is waiting for a person
result.stopped_because  # why a budget stopped the run, or None
session.run()           # id, status, mode, provider, model, finish_reason, tool_calls

The second argument narrows the tools for one turn, and media can ride along with the prompt:

Argument PHP TypeScript / Python
tool names send($prompt, ['lookup_order']) — intersected with the mode's list, so it can only narrow send(session, prompt, ['lookup_order']) — used instead of the mode's list, so pass only names that mode should allow
attachments send($prompt, null, [Image::fromBase64($b64, 'image/png')]) fifth argument, additionalContent / additional_content
run context send($prompt, context: $run) fourth argument, a RunContext

Attachments must be an image, document, audio or video carrying bytes, a provider file id or document chunks. Anything else is refused before a run starts with attachment_by_reference, attachment_not_media, attachment_empty or attachment_without_prompt.

In PHP, send() holds the session lock for the whole run (see Locking). The TypeScript and Python runtimes do not lock around a run; Locking shows how to serialize turns when two workers can send to the same session.

Structured turns#

When the answer is a document rather than prose, hand the turn a schema. It is the same run as send(): same mode, tools, budget and events.

use Prism\Harness\Exceptions\StructuredSchemaViolation;
use Prism\Prism\Schema\ArraySchema;
use Prism\Prism\Schema\ObjectSchema;
use Prism\Prism\Schema\StringSchema;

$schema = new ObjectSchema('plan', 'A proposed plan', [
    new StringSchema('title', 'What the plan is called'),
    new ArraySchema('steps', 'What to do', new StringSchema('step', 'One step')),
], requiredFields: ['title', 'steps']);

try {
    $plan = $session->sendStructured('Plan the release', $schema)->structured();
} catch (StructuredSchemaViolation $violation) {
    $violation->code();      // structured_schema_violation or structured_unreadable
    $violation->problems();  // every way it missed
    $violation->document();  // what the model actually said
}
import { HarnessError } from '@particle-academy/prism-harness';

const schema = {
  name: 'plan',
  type: 'object',
  properties: { title: { type: 'string' }, steps: { type: 'array', items: { type: 'string' } } },
  required: ['title', 'steps'],
};

try {
  const plan = (await runtime.sendStructured(session, 'Plan the release', schema)).structured;
} catch (error) {
  if (error instanceof HarnessError) {
    error.code;     // structured_schema_violation or structured_unreadable
    error.problems; // every way it missed
    error.document; // what the model actually said
  }
}
from prism_harness import HarnessError

schema = {
    "name": "plan",
    "type": "object",
    "properties": {"title": {"type": "string"}, "steps": {"type": "array", "items": {"type": "string"}}},
    "required": ["title", "steps"],
}

try:
    plan = runtime.send_structured(session, "Plan the release", schema).structured
except HarnessError as error:
    error.code      # structured_schema_violation or structured_unreadable
    error.problems  # every way it missed
    error.document  # what the model actually said

In TypeScript and Python your client receives the schema as request.schema and returns the parsed document as structured. A document that misses the schema is refused, never coerced or returned empty. The exchange is still recorded and the run is marked failed. The check covers declared types, required keys, enum members, array items and undeclared keys on closed objects; it does not evaluate $ref, allOf, oneOf or numeric and string facets.

Streaming#

PHP can deliver the same turn as it happens. stream() yields Prism's stream events unchanged and records the same transcript send() would when the stream ends. TypeScript and Python have no streaming turn.

foreach ($session->stream('Refactor the billing job') as $event) {
    // Prism's stream events — render them as you already do
}
// the turn is recorded and the run closed out when the stream ends
// The TypeScript runtime has no streaming turn. Use runtime.send().
# The Python runtime has no streaming turn. Use runtime.send().

The lock is held for the whole iteration. If the consumer stops reading early, the run is still closed and the partial turn recorded.

Approvals#

A mode names the tools a person must approve with requires_approval. A turn that calls one stops and returns its pending approvals; your application records a decision for each and the run continues. Unanswered approvals are denied, never run.

use Prism\Prism\ValueObjects\ToolApprovalRequest;
use Prism\Prism\ValueObjects\ToolApprovalResponse;

$response = $session->send('Clean up the failed run');

if ($response->awaitingApproval()) {
    $response = $session->decide(array_map(
        fn (ToolApprovalRequest $pending) => new ToolApprovalResponse($pending->approvalId, approved: true),
        $response->pendingApprovals(),
    ));
}
import { recordApproval } from '@particle-academy/prism-harness';

let response = await runtime.send(session, 'Clean up the failed run');

if (response.finishReason === 'awaiting_approval') {
  for (const pending of response.pendingApprovals) {
    await recordApproval(session, pending.id, true); // or false, 'not on production'
  }
  response = await runtime.send(session, '');
}
from prism_harness import record_approval

response = runtime.send(session, "Clean up the failed run")

if response.finish_reason == "awaiting_approval":
    for pending in response.pending_approvals:
        record_approval(session, pending.id, True)  # or False, "not on production"
    response = runtime.send(session, "")

Decide who may approve before recording a decision. Approvals covers the full flow in each language, including denying with a reason.

Events#

Each run emits a started event and then either a finished or a failed event, with the run id and the session key. A failed run carries only the exception class (PHP) or an error code or message (TypeScript, Python), never prompt content.

use Illuminate\Support\Facades\Event;
use Prism\Harness\Events\RunFailed;
use Prism\Harness\Events\RunFinished;
use Prism\Harness\Events\RunStarted;

Event::listen(function (RunFinished $event): void {
    $event->runId;
    $event->finishReason;
    $event->steps;
    $event->awaitingApproval;   // the one end state that needs a person to act
    $event->correlation();      // session_key, run_id, parent_run_id, root_run_id
});
import { AgentRuntime, HarnessEvents } from '@particle-academy/prism-harness';

const events = new HarnessEvents();

const stop = events.listen((event) => {
  if (event.type === 'run.finished') {
    console.log(event.runId, event.finishReason, event.steps, event.costUsd, event.toolCalls);
  }
});

const runtime = new AgentRuntime({ client, modes, tools, events });
from prism_harness import AgentRuntime, HarnessEvents, RunFinished

events = HarnessEvents()


def on_event(event):
    if isinstance(event, RunFinished):
        print(event.run_id, event.finish_reason, event.steps, event.cost_usd, event.tool_calls)


stop = events.listen(on_event)

runtime = AgentRuntime(client=client, modes=modes, tools=tools, events=events)
Event PHP class TypeScript / Python type Fields
started RunStarted run.started run id, session key, mode, provider, model; PHP adds parent and root run ids, TS/Py add root run id and depth
finished RunFinished run.finished finish reason, steps; PHP adds awaitingApproval, TS/Py add tool names and costUsd / cost_usd
failed RunFailed run.failed PHP: exception (class name); TS/Py: failure and steps

PHP events are ordinary Laravel events, so they can be queued or broadcast. In TypeScript and Python, listen() returns a function that removes the listener, and a listener that throws is reported as a warning rather than breaking the run.

State stores#

Slot Holds Losing it means
ephemeral active mode, provider, model, run bookkeeping falls back to a default
durable threads, capability state, pending approvals, task lists conversation history, approvals and work are lost

PHP defaults both slots to database. TypeScript and Python default to a volatile memory store, which is refused for the durable slot, so configure a durable driver before opening a session.

// config/prism-harness.php
'stores' => [
    'ephemeral' => 'redis',      // recommended in production
    'durable'   => 'database',
],
const harness = new PrismHarness({
  drivers: {
    memory: () => new MemorySessionStore(),
    files: () => new FileSessionStore('./storage/harness'),
  },
  stores: { ephemeral: 'memory', durable: 'files' },
});
harness = PrismHarness(
    drivers={
        "memory": MemorySessionStore,
        "files": lambda: FileSessionStore("./storage/harness"),
    },
    stores={"ephemeral": "memory", "durable": "files"},
)
Driver Language Durability
database (DatabaseSessionStore) PHP durable
redis (RedisSessionStore) PHP volatile unless drivers.redis.durable is true
MemorySessionStore TS, Python volatile, process-local lock
FileSessionStore TS, Python durable; atomic writes and a cross-process lock on one machine, not across a network filesystem

Durable store validation#

A store that reports itself as volatile is rejected for durable state with UnsafeStateConfiguration (PHP) or unsafe_state_configuration (TypeScript, Python). PHP's Redis driver is volatile by default. Set durable to true only if that Redis instance persists its data and nothing flushes it.

TypeScript and Python stores declare durability through the SessionStore contract. For your own driver, return 'durable' from durability() only when it is true of the infrastructure:

'drivers' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => env('HARNESS_REDIS_CONNECTION', 'default'),
        'durable' => true,   // an assertion about your infrastructure, made deliberately
    ],
],
const harness = new PrismHarness({
  drivers: { redis: () => durableRedisStore },
  stores: { ephemeral: 'redis', durable: 'redis' },
});

// durableRedisStore.durability() must return 'durable'.
harness = PrismHarness(
    drivers={"redis": lambda: durable_redis_store},
    stores={"ephemeral": "redis", "durable": "redis"},
)

# durable_redis_store.durability() must return "durable".

The context window#

Compaction limits the conversation history sent to the model without deleting the stored thread. Built-in compaction is PHP-only: TypeScript and Python send the complete thread, and you can trim request.messages inside your client.

In PHP, compaction is off by default:

// config/prism-harness.php
'context' => [
    'keep_recent' => 20,          // a bounded window: free, deterministic
    // 'summarise_with' => 'claude-haiku-4-5-20251001',
    // 'summary_words'  => 200,
],
// No built-in compaction. The full thread reaches your client as request.messages.
# No built-in compaction. The full thread reaches your client as request.messages.
Piece What it does
CompactionStrategy Decides what the model sees. NoCompaction (default), KeepRecentTurns, SummarisingCompaction — or your own
EvictionSink Receives evicted messages so they can be retrieved later. The default discards them
ContextRecall Backs a recall_context tool, offered to the model only when you bind an implementation
SummaryBudget How a summary's word budget is enforced — RetryOnce (default), AskOnly, TruncateTo

keep_recent counts messages, not turns, and a tool call and its result are always kept or dropped together. SummarisingCompaction makes a model call whenever compaction runs, and may make another if the summary is over budget. It cannot be combined with a hard cost cap; a capped run refuses it with budget_compaction_unmetered.

Summary information loss

Summaries can omit details and constraints. Bind an EvictionSink to keep the original messages reachable, and keep required application policies in the system prompt rather than in summarized history.

summary_words asks for a word limit in the summarization prompt; a model may not honour it. RetryOnce asks once more when the summary is over the limit, TruncateTo cuts the result to the limit, and AskOnly accepts what came back:

use Prism\Harness\Context\Budget\TruncateTo;
use Prism\Harness\Contracts\SummaryBudget;

$this->app->bind(SummaryBudget::class, fn () => new TruncateTo);
// SummaryBudget is PHP-only.
# SummaryBudget is PHP-only.

Voice#

PHP's VoiceExchange takes one spoken utterance, transcribes it, sends the transcript as an ordinary turn on the session, and speaks the answer back. The thread stores the transcript, not the audio. TypeScript and Python have no voice helper.

use Prism\Harness\Voice\VoiceExchange;
use Prism\Prism\ValueObjects\Media\Audio;

$reply = (new VoiceExchange)->exchange($session, Audio::fromBase64($base64, 'audio/webm'));

$reply->heard;   // the transcript — what the model was asked
$reply->text;    // the answer
$reply->audio;   // the answer spoken (a GeneratedAudio), or null when there was no prose
$reply->empty;   // true when nothing was heard; no turn was spent
// No voice helper. Transcribe with prism-ts, then runtime.send() the transcript.
# No voice helper. Transcribe with prism-ai-core, then runtime.send() the transcript.

The constructor takes transcribeModel (default whisper-1), speakModel (tts-1), voice (alloy), transcribeProvider and speakProvider (both OpenAI). Audio built from a URL or a file path is refused with UnsafeAudioSource (unsafe_audio_source); pass allowReferencedAudio: true only for recordings your own application wrote.

Audio in stack traces

When a provider call fails, the exception's stack frames hold the Audio argument. If zend.exception_ignore_args is off, an error reporter can copy a voice recording into your logs. Set zend.exception_ignore_args=1, or scrub Prism\Prism\ValueObjects\Media\Audio in your error reporter.

Configuration#

PHP reads config/prism-harness.php. TypeScript and Python take the equivalent values in constructors: stores and drivers on PrismHarness, modes on ModeRegistry, and budgets on RunBudget.

Key Default Env Meaning
default_scope default HARNESS_DEFAULT_SCOPE Scope used when session() is called without one
ephemeral_ttl 86400 HARNESS_EPHEMERAL_TTL Seconds the ephemeral half lives untouched; null keeps it until forgotten. Only an integer is applied, so set it in the config file rather than from the environment, which delivers a string
context.keep_recent null HARNESS_KEEP_RECENT Messages kept by KeepRecentTurns; null turns compaction off
context.recall_budget 1000 HARNESS_RECALL_BUDGET Token ceiling on what recall_context returns
context.summarise_with null HARNESS_SUMMARISE_WITH Model for SummarisingCompaction; setting it turns that strategy on
context.summary_words 200 HARNESS_SUMMARY_WORDS Requested summary length
stores.ephemeral database HARNESS_EPHEMERAL_STORE Driver for the ephemeral slot
stores.durable database HARNESS_DURABLE_STORE Driver for the durable slot
drivers.redis.connection default HARNESS_REDIS_CONNECTION Redis connection
drivers.redis.prefix harness: HARNESS_REDIS_PREFIX Key prefix
drivers.redis.durable false HARNESS_REDIS_DURABLE Allow Redis in the durable slot
drivers.database.connection null HARNESS_DB_CONNECTION Database connection; null is the default connection
drivers.database.table harness_session_state — State table
tasks.lease_seconds 300 HARNESS_TASK_LEASE_SECONDS Task claim lease; must be a whole positive number
tasks.lock_wait 5 HARNESS_TASK_LOCK_WAIT Seconds a task-list operation waits for the list lock
pricing [] — Trusted prices per provider and model, required by hard cost caps (Subagents and budgets)
cost_observer null — Container-resolved CallCostObserver class for capped runs
agent.provider anthropic HARNESS_PROVIDER Provider when the session has none
agent.model claude-sonnet-4-5 HARNESS_MODEL Model when the session has none
agent.lock_ttl 300 HARNESS_RUN_LOCK_TTL Seconds a run's session lock lasts
agent.lock_wait 0 HARNESS_RUN_LOCK_WAIT Seconds a run waits for a busy session before SessionLocked
agent.authorize_tools false HARNESS_AUTHORIZE_TOOLS Turns on the harness.tool and harness.tool.call Gate checks
agent.max_steps_ceiling 40 HARNESS_MAX_STEPS_CEILING Upper bound on usingMaxSteps(); null removes it
agent.max_output_tokens_per_call null — Output token ceiling per model call; required by hard cost caps
agent.max_cost_usd not set — Hard cost cap for a root run
agent.max_seconds not set — Wall-clock limit for a root run
agent.default chat — Mode used when the session has none
agent.modes a chat mode — Mode definitions; see Modes, tools and skills

Errors#

PHP throws a class per failure. Most implement code() with a stable string. TypeScript and Python throw one HarnessError whose code carries the same strings.

PHP exception Code When
SessionLocked — The session lock was not acquired within the wait
UnsafeStateConfiguration unsafe_state_configuration A volatile store in the durable slot or behind a task list, or an unknown driver
UnsafeAuthorizationConfiguration — A harness.tool Gate ability is defined while authorize_tools is off
RunNotPermitted run_not_permitted, or a budget code The run tree's budget is exhausted or a capped call cannot be admitted
CallRefused your code Thrown by your CallCostObserver to refuse a call
StructuredSchemaViolation structured_schema_violation, structured_unreadable A structured answer missed the schema or was unreadable
UnacceptableAttachment attachment_by_reference, attachment_not_media, attachment_empty, attachment_without_prompt A turn's media was refused
UnsafeAudioSource unsafe_audio_source VoiceExchange was given audio by URL or path
UnmappableContent — A message cannot be stored or rebuilt without loss
ToolNotAvailable — The fancy-flow integration asked for a tool it was not given
InvalidTaskIdentifier task_identifier_blank, duplicate_task_id A blank worker or task id, or a reused task id
InvalidTaskLease task_lease_invalid A lease that is not a whole positive number of seconds
InvalidTaskOutcome task_outcome_invalid An outcome other than done or failed
TaskNotReleasable task_not_found, task_already_terminal, task_lease_not_held release() by someone other than the current holder, or of a finished task
LeaseNotExtendable task_not_found, task_lease_not_held, run_not_permitted extendLease() refused
UnmappableTask — A task model or stored record cannot be read as a task

The TypeScript and Python codes are: session_locked, unsafe_state_configuration, unknown_store_driver, tool_not_available, run_not_permitted, unmappable_content, no_agent_runtime, mode_not_configured, mode_malformed, unsafe_authorization_configuration, call_not_authorized, skill_path_refused, duplicate_task_id, task_identifier_blank, task_not_found, task_already_terminal, task_lease_not_held, task_outcome_invalid, task_lease_invalid, the four attachment_* codes, structured_unreadable and structured_schema_violation. In PHP an unknown or malformed mode throws InvalidArgumentException.

Checking your configuration#

Resolve every mode, store and authorization setting up front, instead of discovering a broken mode when a user first switches to it.

// php artisan harness:doctor
//
//   stores ............................................ durable is durable
//   authorization ..... off — every registered tool is offered to every run
//   mode chat ...................... 0 tool(s), 0 subagent(s), 8 step(s)
//
//   INFO  Harness configuration is consistent.
import { SessionStoreManager, ToolAuthorizer, diagnose } from '@particle-academy/prism-harness';

const report = diagnose({
  modes,
  tools,
  stores: new SessionStoreManager({ drivers: { files: () => new FileSessionStore('./storage/harness') }, stores: { ephemeral: 'files', durable: 'files' } }),
  authorizer: new ToolAuthorizer(),
});

console.log(report.summary());
process.exitCode = report.ok ? 0 : 1;
from prism_harness import FileSessionStore, SessionStoreManager, ToolAuthorizer, diagnose

report = diagnose(
    modes=modes,
    tools=tools,
    stores=SessionStoreManager(
        drivers={"files": lambda: FileSessionStore("./storage/harness")},
        stores={"ephemeral": "files", "durable": "files"},
    ),
    authorizer=ToolAuthorizer(),
)

print(report.summary())

The doctor reports each mode's tool count (a ['*'] mode is reported as all registered tools), subagents, step limit and approval list, and fails a mode whose requires_approval names a tool the mode does not offer. harness:doctor exits non-zero when it finds a problem.

Package concepts#

Concept What it does
Thread The durable record of an exchange, appended to as it grows
Session Resolved per request, keyed on participant and scope
State stores Pluggable drivers in separate ephemeral and durable slots; volatile durable storage is refused
Agent runtime Runs a mode's tools and step limit, records messages and run bookkeeping
Modes A system prompt, a tool allowlist, approvals, skills, subagents and a step limit
Permissions Mode allowlists plus optional offer-time and call-time authorization
Approvals Tools that wait for a person, denied when unanswered
Subagents A nested run with its own mode, session and a budget drawn from the parent
Task lists Durable, leased work items an agent claims across runs
Events Run started, finished and failed

A scoped filesystem for an agent to work in lives in Workspace.

Read next: Sessions and threads, Modes, tools and skills, Subagents and budgets and Task lists.

Source and issue trackers: PHP, TypeScript, and Python.