particle-academy / prism-harness
Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.
Requires
- php: ^8.2
- laravel/framework: ^12.61.1|^13.12.0
- particle-academy/prism: >=0.113 <1.0
Requires (Dev)
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-08-27 00:08:10 UTC
README
Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.
Status: threads and sessions work; the rest is still design. Conversation persistence and session rehydration are implemented and tested. Modes, permissions and subagents are recorded below as decisions, not code yet.
Working on this package? Read
AGENTS.mdfirst — the boundary this package has to hold, the gates that must be green, and the traps that have already caught someone.@link AGENTS.md
Sessions
$session = PrismHarness::for($user)->session('support'); $session->usingMode('plan')->usingModel('claude-sonnet-4-5'); $session->lock(function (Session $session) { // whatever must not happen twice });
Resolved, never held. A Laravel request boots, serves and dies, so a session cannot be an object kept in memory the way Mastra's is. Every call rebuilds one from a store, which is what makes a fresh worker see the same mode, model and conversation as the request that set them.
The two halves
State is split into named slots, because the halves have genuinely different requirements:
| Slot | Holds | Losing it means |
|---|---|---|
ephemeral |
active mode, selected model, run bookkeeping | falls back to a default |
durable |
threads, pending tool approvals | work is gone |
Configure them independently — Redis for the first, database for the second is the intended shape:
Both default to database, so the package works on install with nothing to set up. Point the
ephemeral half at Redis when you have one — it is the better home for live session state, and
opting in beats a default that throws a connection error on a machine that never claimed to
run Redis:
'stores' => [ 'ephemeral' => 'redis', // recommended in production 'durable' => 'database', ],
Why the durable slot is guarded
A store that reports itself volatile is refused for durable state, loudly, at resolve time.
Redis is the natural home for live session state, but the redis connection in a typical
Laravel app is a cache — something is entitled to flush it. The package cannot tell from the
inside whether yours is persistent, so redis reports Volatile by default and pointing the
durable slot at it throws UnsafeStateConfiguration with both ways out named.
This is not defensive theatre. A sibling project in this workspace kept XP de-duplication in a
cache a deploy could clear; a single cache:clear between two backfills would have silently
re-awarded every contribution, with nothing in the logs. The same mistake here loses a pending
tool approval — a half-executed action a human was asked to authorise — which does not degrade
to a default.
If your Redis really is durable (AOF or RDB), say so and it is allowed:
'drivers' => [ 'redis' => [ // An assertion about your infrastructure, not a preference. 'durable' => true, ], ],
Concurrency
Two workers can hold the same session at once — a queued job finishing a run while the user
sends another message is ordinary. lock() takes an exclusive lock and throws
SessionLocked rather than running anyway on timeout, since running anyway would defeat the
only thing it is for. Locks carry an expiry, so a worker that dies mid-run does not hold the
session shut forever.
Threads
The first piece, and the one everything else needs. Prism 0.113 added a Thread contract —
a stored conversation it can read history from — and this package provides the Eloquent
implementation.
use Prism\Harness\Models\Thread; $thread = Thread::forParticipant($user, 'support'); $response = Prism::text() ->using(Provider::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
$response->messages carries every step of a tool loop, so recording a turn is one call and
a run interrupted mid-tool resumes exactly where it stopped.
Addressed by participant and scope. One user holds several unrelated conversations at
once — a support chat and a coding session are not the same thread — so the scope is part of
the address, not a label hung off it. Thread::forParticipant($user, 'coding') resolves a
different conversation from 'support', and a fresh worker asking for the same address lands
on the same thread rather than starting a new one.
The storage format is ours, not Prism's. Prism's toArray() exists to feed telemetry and
debug output and is free to change for presentational reasons; persistence cannot be, so it
does not ride on it. Two consequences worth knowing:
- Content parts are stored with their concrete class. Prism's
Media::toArray()records where a file lives but not what it is — an Image and a Document serialise identically — so without that, every attachment would come back as whatever type we guessed. - Anything that cannot be stored or rebuilt faithfully throws
UnmappableContentrather than being dropped. A thread is replayed to the model as context, so a silent omission does not surface as an error; it surfaces much later as a model that has forgotten something.
Who can write your thread rows
Prism's contract warns that stored history is replayed to the model, so it is only as
trustworthy as the store it came from. Threads make that concrete: rebuilding an attachment
resolves whatever locator was recorded, so a row carrying a local_path or url becomes a
file read or an outbound fetch at replay time. Rehydration is restricted to Prism Media
subclasses, but the locator itself is data.
None of this is reachable without write access to your database — at which point the thread
table is not your first problem. It matters because it sets where the boundary is: treat
harness_thread_messages as trusted storage, and never let request input write directly to
it.
What it is for
Applications where the agent is the product and the session is long-lived — an interactive coding assistant, a support console, an operations agent.
That is a different thing from an AI feature inside an app, which is what Prism and
laravel/ai already serve well: prompt, respond, maybe stream.
A harness is what you need when the conversation outlives the request, the agent switches
between ways of working, and a tool call has to stop and wait for a human.
Nothing in PHP serves that today.
The constraint that shapes everything
The prior art is Mastra's Harness (their class is now
AgentController). It cannot be ported.
Mastra keeps a Session in memory because Node holds one process across many requests. Their docs are explicit that session state, permission grants and pending approvals "don't automatically survive process recreation".
A Laravel request boots, serves and dies. So the architecture inverts:
| Mastra | a live object with optional persistence |
| Prism Harness | durable state with a reconstructed runtime |
Two properties follow, and neither is negotiable:
- Nothing is held across requests. A fresh worker resolves the same session and sees the same mode, model and pending approvals.
- A pending approval outlives everything — the request that created it, the worker that ran it, and a deploy in between. Mastra can treat an approval as an in-memory promise. Here it is a row.
Intended shape
$session = PrismHarness::for($user)->session(); // rehydrated, not constructed $session->mode('plan'); // persisted on the thread $response = $session->send('Refactor the billing job'); if ($response->awaitingApproval()) { $session->approve($response->pendingApprovals()->first()); }
Concepts, and what each maps to
Every Mastra concept has a native Laravel counterpart. Where the mapping is exact, the plan is to use the Laravel thing rather than reimplement it.
| Concept | Status | Laravel counterpart |
|---|---|---|
| Controller | planned | Singleton in the container; config file plus mode classes |
| Session | shipped | Resolved per request from a store, keyed on participant + scope |
| Thread | shipped | Eloquent models here; contract defined in Prism (0.113) |
| Modes | planned | One class per mode, container-resolved so they are testable |
| Workspace | elsewhere | A scoped Filesystem disk — built as prism-workspace |
| Permissions | planned | Gates and Policies — "may this tool run" is an authorization question |
| Subagents | planned | A Prism Tool wrapping a nested run, with a narrowed toolset |
| Event bus | planned | Laravel events over Reverb — a harness stream, separate from Prism telemetry |
Every row states its status, because the previous version of this table did not
and it misled someone. Bold was doing two jobs: marking Session and Thread
as built, and emphasising Gates and Policies as a design choice. Identical
weight, identical position, different meaning — so the planned row read exactly
like the shipped ones, and a reader concluded this package gates tools on
Laravel Gates. It does not; there is no Gate reference anywhere in src/.
That reader then told two other agents, one of which built on it. A status line four lines above a table does not travel with the row someone quotes.
Decisions already taken
| Question | Decision | Why it matters |
|---|---|---|
| Where threads live | Contract in Prism, Eloquent implementation here — shipped | Prism keeps no storage opinion; anything can satisfy the interface |
| Event bus | A separate harness stream | Telemetry is observability, harness events are interface — different audiences and stability guarantees |
| State store | Redis-first behind a configurable driver — shipped | Redis and database behind one contract, with the durable slot guarded |
| Package | particle-academy/prism-harness |
Its own repo under the Particle Academy brand |
On Redis
Redis is the natural fit for live session state, but in most deployments it is a cache, and a cache is disposable by definition. The driver contract must therefore distinguish:
- Ephemeral — active mode, current model, run bookkeeping. Losing it degrades to a default.
- Durable — threads and pending approvals. Losing these means a half-executed agent action disappears.
A configuration pointing durable state at a volatile store should fail loudly rather than accept
it. This is not hypothetical caution: a sibling project in this workspace lost de-duplication
state to exactly that pattern, where a single cache:clear between two runs would have silently
double-awarded everything.
Still open
- Whether mode is owned by the session or the thread. Mastra treats it as session state but persists it on the thread; those come apart when one participant holds several sessions over one thread.
- Whether subagent step budgets nest or reset.
Background
Full analysis — including a gap comparison against laravel/ai — lives in the envelope at
.ai/discovery/laravel-ai-sdk-and-prism-harness.md.