Version 0
MortelOS Sidecar
Hands heavy chat questions to Claude Code on the asking user's own Claude account, in a closed sandbox.
mortelos/sidecar hands heavy chat questions to Claude Code. It runs on the asking user's own Claude account, in a closed sandbox. Chat runs have a 30-minute limit by default; standalone tasks default to 600 seconds, and workflow tasks allow at most 600 seconds. The sidecar reads data only through read-only agent tools under the asker's policy and calculates in Python. Its answer and files go back into the conversation. The same runner also runs standalone tasks and sidecar.task workflow steps. This page describes version 0.2.6.
The user-facing texts are in Dutch. They can be changed in config and per tenant.
Use It For
| Area | Package responsibility |
|---|---|
| Handover | The sidecar.start agent tool. A chat run that hits its runtime or tool budget is also handed over. |
| Account connection | Each user connects their own Claude account through Claude's own sign-in flow. |
| Sandbox | Runs Claude Code with its Bash sandbox (bubblewrap and socat). The sandbox has no network domains, and web fetch and web search are off. |
| Data access | Only allow-listed agent tools with read risk that the asker's policy allows and that need no approval. Results are written as CSV files. |
| Output | Files the sidecar writes to output/ become downloads under the answer, after hardening and checks. |
| Follow-up | When the sidecar asks a question, the user's reply goes to a new sidecar run. |
| Tasks | Standalone tasks without a conversation, and the sidecar.task workflow step. |
| Settings | An admin settings page and per-tenant overrides in tenant_settings. |
Requirements
Composer: PHP 8.4 with ext-dom, mortelos/framework ^0.6.31, mortelos/chat ~0.3.21.5 || ^0.3.27 || ^0.4 || ^0.5, mortelos/ui ^0.4.6 || ^0.5 and Livewire ^4.0. The framework constraint stays within 0.6; it does not include 0.7.0.
Server:
- Claude Code. Install the Claude Code CLI and set its absolute path in
SIDECAR_CLAUDE_BINARY. PHP-FPM gives web requests noPATH, so a bare name is not found when a user connects. - Sandbox tools.
bwrapandsocatmust be on thePATHthat Claude gets, or the run does not start. UseSIDECAR_SANDBOX_PATH_PREPENDorSIDECAR_BWRAP_DIRwhen the system has none. - Python 3. Set
SIDECAR_PYTHON. The default instructions use matplotlib, openpyxl, python-docx and reportlab for output files, and skip a format when a library is missing. If a Python environment lives under a denied path such as/home, add it withSIDECAR_SANDBOX_READ_PATHS. - Queue worker. Run a worker for the sidecar queue (
SIDECAR_QUEUE, defaultsidecar). The job timeout ismax_secondsplus 60. Set the queue connection'sretry_afterclearly above that, for example 1920 for the default 1800, or a second worker picks the job up again. - Shared cache. The run slots are cache locks in the default cache store. All workers must share it (redis or database, not array or file per host).
- Event sourcing. A user's Claude connection is a personal channel of type
sidecar_personal. List the framework'sChannelProjectorinconfig/event-sourcing.php. - Tenant settings. Per-tenant overrides need the
tenant_settingstable. Without it, the package reads config only.
Install
composer require mortelos/sidecar
php artisan vendor:publish --tag=sidecar-config
php artisan migrate
Packages resolve from the MortelOS registry. Configure registry access once before installing; see Installation.
The service provider is auto-discovered and loads the package migrations. In a published config/sidecar.php, the texts block is merged per key, so you can keep only the texts you change. Every other block replaces the package block as a whole, such as a shorter sandbox_deny_read.
Switch It On
- Set
SIDECAR_ENABLED=true. - Set
pilot_rolesto the role names that may use the sidecar in the tenant. An empty list allows nobody. - Allow
agent_tool:sidecar.startin the agent tool policy for those roles. It is deny-by-default. - Set
tool_allowlisttofnmatch()patterns of the read tools the sidecar may call. An empty list gives it no data tools. - Let users connect their Claude account.
Connecting Claude
The package registers two Livewire pages under route_prefix (default diepteonderzoek) with route_middleware (default web, auth):
| Path | Route name | Purpose |
|---|---|---|
/koppelen |
sidecar.connect |
The step where a user connects their Claude account. |
/instellingen |
sidecar.settings |
The admin settings page. |
The Livewire components are sidecar::connect-account, sidecar::account-card, sidecar::settings and sidecar::pages.connect-account. The account card shows connection state and quota, with reconnect and disconnect buttons.
To prompt users after login, call Mortelos\Sidecar\Connection\SidecarOnboarding::shouldPrompt($userId) and redirect to sidecar.connect when it returns true. It is true when the sidecar is on, the user has a pilot role, the account is not connected and the user did not skip the step in this session.
The sidecar.connect gate decides who may connect. By default it allows pilot users. The package defines it only when the host has not, so a host can define its own, for example to refuse impersonation sessions.
| Setting | Effect |
|---|---|
login.allowed_subscription_types |
Claude plans that may connect: pro, max, team, enterprise. Empty allows every plan. |
login.allowed_email_domains |
E-mail domains of accounts that may connect. Empty allows every account. Users with the sidecar.connect-external ability may connect any account. |
login.code_ttl_seconds |
How long a started login waits for the pasted code. Default 900. |
The login lives in a per-user directory under login.login_root (SIDECAR_LOGIN_ROOT). Only the Claude binary reads and refreshes it. The app never reads, copies or stores the credentials; the database holds connection state only. Keep login_root outside anything the sandbox may read. The runner always denies the sandbox access to the work root and the login directories.
Users on a personal Claude plan (Free, Pro or Max) see a notice to switch off model training in their Claude privacy settings.
How A Question Reaches The Sidecar
A chat run may go to the sidecar when the sidecar is on and the run is a chat run with a conversation. The asker must have a pilot role, a working Claude connection, Claude quota and no other sidecar queued or running.
- Tool. The chat model calls
sidecar.startwith areasonand a list ofassumptions. The chat ends its turn with a short handoff text. The sidecar is queued once the chat run has completed and posts its answer in the conversation. - Safety net. A chat run that hits its runtime or tool-iteration budget goes to the sidecar when the asker may use it. Otherwise it ends with a message that says why, and what was found so far.
- Not connected. The question is parked (only the latest one) and the chat shows a connect widget. Once the user connects, the question continues by itself.
- Busy. Each user has at most one sidecar at a time. The chat tells the user that an earlier question is still being worked on.
- Follow-up. When the sidecar asks the user a question, the reply goes to a new sidecar run. The answer is added to the assumptions.
The sidecar gets the original message, the conversation, the chat's earlier steps and the assumptions in handoff/. Tool results go to results/ as CSV. Claude receives the instructions setting as an addition to its system prompt and the task_prompt setting as its prompt.
Output Files
Files the sidecar writes directly in output/ become downloads under its answer. Other files are ignored.
- Allowed extensions:
csv,xlsx,docx,pdf,html,svg,png,jpg,md,txt.output.extensionscan only narrow this list. - At most 10 files (
output.max_files) of up to 10 MB each (output.max_file_bytes). - An HTML file is rebuilt as a static page. Scripts, forms and everything that points outside the page are removed. A standalone SVG is rebuilt as a static drawing in the same way.
- A Word, Excel, PDF or Markdown file with a link or reference to outside is not handed out.
- A CSV is attached unchanged unless it has cells that look like formulas. Those cells are prefixed with an apostrophe. An outside reference alone does not cause refusal. A read or processing failure does.
Limits
| Limit | Default |
|---|---|
Run time (max_seconds) |
1800 seconds, at least 60. Claude is interrupted 60 seconds before the deadline (interrupt_lead_seconds). |
Runs at the same time on the server (max_concurrent) |
2 in total, at most 1 per user. |
Wait for a free slot (max_queue_seconds) |
3600 seconds. After that, the user is asked to try again later. |
| Network | None for Bash. Web fetch and web search are denied. |
Engine (engine) |
claude only. An unknown engine fails when the sidecar is used. |
| Quota | The user's own Claude quota. When it runs out, the user cannot use the sidecar until the reset time. |
Settings Per Tenant
A tenant setting sidecar.<key> overrides config('sidecar.<key>'), so limits, roles, instructions and texts change without a deploy. Jobs read tenant values only inside tenant context.
Keys that decide what the sandbox can reach come only from config: engine, claude_binary, bwrap_dir, sandbox_path_prepend, sandbox_deny_read, sandbox_read_paths, python_binary, work_root and login.login_root.
The settings page edits these keys per tenant:
| Group | Keys |
|---|---|
| Access | enabled, pilot_roles, login.allowed_subscription_types, login.allowed_email_domains |
| Limits | max_seconds, max_concurrent |
| Model and instructions | model, effort, instructions, start_tool_guidance |
| Data | tool_allowlist |
| Tool sharing | skills.promotion_min_uses, skills.shared_cap, skills.expiry_days |
| Texts | The user-facing texts |
Saving goes through the framework's tenant setting writer, which requires tenant-settings.write. Only values that differ from the current value are written.
The name the sidecar uses in texts comes from assistant_name (SIDECAR_ASSISTANT_NAME), then mortel.assistant_name, then app.name.
Configuration
| Key | Environment variable | Default |
|---|---|---|
enabled |
SIDECAR_ENABLED |
false |
pilot_roles |
[] |
|
queue |
SIDECAR_QUEUE |
sidecar |
max_seconds, max_concurrent, max_queue_seconds |
1800, 2, 3600 |
|
engine |
SIDECAR_ENGINE |
claude |
claude_binary |
SIDECAR_CLAUDE_BINARY |
claude |
model, effort |
opus, high |
|
sandbox_read_paths |
SIDECAR_SANDBOX_READ_PATHS (colon-separated, added) |
/usr |
sandbox_deny_read |
/home, /Users, /root, /run, /var/run, /var/www, /srv |
|
sandbox_path_prepend |
SIDECAR_SANDBOX_PATH_PREPEND |
empty |
bwrap_dir |
SIDECAR_BWRAP_DIR |
empty |
python_binary |
SIDECAR_PYTHON |
python3 |
work_root |
SIDECAR_WORK_ROOT |
storage/app/sidecar |
display_timezone |
SIDECAR_DISPLAY_TIMEZONE |
Europe/Amsterdam |
route_prefix, route_middleware |
diepteonderzoek, ['web', 'auth'] |
|
tool_allowlist |
[] |
|
memory.enabled |
SIDECAR_MEMORY_ENABLED |
false |
output.extensions, output.max_files, output.max_file_bytes |
see Output Files | |
tasks.* |
see Standalone Tasks | |
login.login_root |
SIDECAR_LOGIN_ROOT |
storage/app/sidecar-logins |
The sandbox may read everything that is not in sandbox_deny_read. Deny every directory that holds unix sockets, because the sandbox must allow unix sockets. sandbox_read_paths re-allows paths inside a denied area, and none of them may contain a denied path.
Extension Points
| Seam | Use it to |
|---|---|
Mortelos\Sidecar\Contracts\SidecarMemory |
Supply the shared definitions the sidecar starts from, in memory/, and receive proposed changes for review. Nothing is applied directly. The default is no memory; bind your own and set memory.enabled. |
Mortelos\Sidecar\Contracts\SidecarRunner |
Change what happens around a run by decorating the binding with app()->extend(SidecarRunner::class, ...). Do not subclass the engine runner. Write into the workdir only through Mortelos\Sidecar\Runner\WorkdirFiles. |
Mortelos\Sidecar\Tasks\TaskPreparation |
Stage task input and validate task results. The default stages authorized artifacts. |
Mortelos\Sidecar\Tasks\TaskDelivery |
Deliver a finished task. The default makes an inbox card or emits an event. |
sidecar.connect gate |
Decide who may connect a Claude account. |
Mortelos\Sidecar\Tasks\TaskResultReady event |
React when a task has a durable result. |
Standalone Tasks
A host can run the sidecar without a conversation:
use Mortelos\Sidecar\Tasks\StartTask;
use Mortelos\Sidecar\Tasks\TaskRequest;
$task = app(StartTask::class)->start(new TaskRequest(
tenantId: $tenantId,
userId: $userId,
identity: 'weekly-summary:2026-42',
instructions: 'Answer only from the supplied files.',
prompt: 'Summarise the input files.',
tools: [],
destination: 'result',
jsonResult: true,
));
- The task runs as a real user. That user must exist, have a role in the tenant and have pilot access, and the sidecar must be on. There is no system actor.
identityis unique per tenant, up to 191 characters. Starting the same task again returns the same task and dispatches its job again. A different definition or user under the same identity fails. A new paid attempt needs a new identity.modelmust be empty or equal the installation's model. The installation's model and effort are saved with the task.deadlineSecondsdefaults to 600 and is capped bymax_seconds.toolsholds exact registry names. An empty list gives the task no tools.inputsholds{artifact_id, checksum, path}references with a SHA-256 checksum. Inputs and outputs must be ordinary text files within thetasks.*limits. Links, hidden configuration, credential file names and binary files are refused.- Personal memory and personal skills are off unless
personalMemoryorpersonalSkillsis set. Memory also needsmemory.enabled.
tasks.* key |
Default |
|---|---|
input_max_files |
1000 |
input_max_bytes |
20 MB |
output_max_files |
100 |
output_max_bytes |
10 MB |
preview_max_chars |
4000 |
The outcome is one of answered, question, usage_limit_reached, login_required, deadline_exceeded, stopped, failed or invalid_output. Only answered is a success. The answer and output files are stored as an internally classified JSON artifact owned by the user, before delivery.
Read the result with app(TaskResults::class)->read($task, $userId), inside the task's tenant and with the real executing user. Do not expose it as a public endpoint.
destination |
Delivery |
|---|---|
inbox (default) |
One internal inbox card for the user, with a preview of the answer. |
result |
Only the TaskResultReady(tenantId, taskId, agentRunId) event. Listeners read the durable result. |
A failed delivery leaves delivery_error set to delivery_failed and keeps the result. Dispatch DeliverTaskJob($tenantId, $taskId) to retry the delivery only. A custom TaskDelivery must be idempotent per task and do local work only.
Workflow Step
The package registers the sidecar.task step type with the framework's workflow step registry. The host must first:
- Run the framework's workflow-step wait migration.
- Register synchronous
WorkflowRunProjectorandWorkflowStepWaitProjectorprojectors with Spatie event sourcing, and includeWorkflowStepWaitRegisteredandWorkflowStepWaitResolvedinevent-sourcing.event_class_map. - Bind
TenantContextandTenantResolver, and keepmortel:workflows:run-duerunning for recovery.
Example step:
{
"id": "research",
"type": "sidecar.task",
"config": {
"instructions": "Research only the supplied input and return JSON.",
"prompt": "Prepare the summary from the input files.",
"tools": [],
"inputs": [],
"model": "opus",
"effort": "high",
"deadlineSeconds": 600,
"jsonResult": true
},
"on_failure": {"behavior": "abort"}
}
modelandeffortare required and must equal the installation's settings when the task is created.deadlineSecondsmust be from 1 to 600, and is capped by the installation limit.- The workflow run snapshot must contain
executor_user_id. The host must bind the framework'sWorkflowStepAuthorizer. - The step reads its config from the run's saved definition, not from the live workflow or the payload.
- The task identity is
workflow:<run-id>:<step-index>. A workflow retry reuses the task and never starts another paid run. - Workflow tasks always use
destinationresult, with personal memory and personal skills off. Thetoolslist still grants the named agent tools; an empty list grants none.
Only answered completes the step, with this result:
{"task_id": "<task-id>", "artifact_id": "<artifact-id>", "checksum": "<sha256>"}
Every other outcome fails the step as task_<outcome> and follows the step's failure behaviour. A later step reads the bytes with TaskResults::read() as the current executor. Keep the framework's workflow recovery running: it completes a step whose notification was missed.
Outlets
An outlet is a place where you extend a package from your app without changing it; read Outlets for how each kind works. List the outlets the installed version declares with:
php artisan mortelos:outlets:list sidecar
Boundaries
The package owns the handover, the Claude connection flow, the sandboxed runner, output checks, settings, standalone tasks and the workflow step. The host app owns the server setup (Claude Code, bubblewrap, socat, Python, queue worker, shared cache), pilot roles and agent tool policies, which tools are allow-listed, the memory source, task preparation and validation, and which users may run tasks. The sidecar never changes data: it reads through read-only tools and proposes memory changes for review.