cognesy/instructor-tell

AXI-oriented CLI for running and inspecting Instructor agents

Maintainers

Package info

github.com/cognesy/instructor-tell

Homepage

Issues

pkg:composer/cognesy/instructor-tell

Transparency log

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

v2.9.5 2026-08-30 02:00 UTC

README

The supported SDK, CLI, persistence, event, trace, and exit contracts are tracked in COMPATIBILITY.md.

Cold-start and discovery-scan budgets are tracked in STARTUP_BASELINE.md.

The static host primitive and rejected Context/Layer adapter are documented in STATIC_COMPOSITION_DECISION.md.

Application replacement seams and their dependency rules are documented in CONTRACTS.md.

The minimal factory-backed composition boundary is documented in HOST.md.

tell is a small, non-interactive reference frontend for cognesy/agents. It loads an agent template, builds the runtime through public APIs, and follows the Agent eXperience Interface at its shell boundary.

tell
tell "summarize this repository"
tell describe --json
tell auth status openai
tell planes --full
tell tools --fields=name,description,deferred
tell agents
tell sessions

With no prompt, tell shows help: where a turn would run, which agents are available, and what to type next. --output=toon and --output=json return the same discovery as data; the formats that exist only to carry an answer have no form for this screen and say so.

A turn defaults to --output=human: the answer rendered as Markdown for a terminal. It decorates only when stdout is a terminal, so a redirected or piped turn stays the plain Markdown the model wrote and remains usable as input to something else. Use --output=toon for TOON, --output=text for the raw final answer undecorated, --output=json for JSON terminal state, or --output=events for a payload-free NDJSON stream using the versioned tell.event.v1 envelope. List commands accept --fields for a smaller schema; session detail is bounded unless tell sessions show ID --full is requested.

Put a prompt that matches a subcommand name after --, for example tell -- agents. The explicit tell tell "agents" form is also available.

Use --session NAME to persist and continue a conversation. Without that option, Tell performs no session storage I/O.

PHP SDK

Tell is also controllable directly from PHP. The default request is stateless; call durable() only when the application deliberately wants workspace history. Use event callbacks for live lifecycle data and runStream() when a worker, HTTP stream, or UI needs completed tool/inference checkpoints without parsing terminal output.

use Cognesy\Tell\Tell;
use Cognesy\Tell\Data\TellRequest;

$tell = Tell::open(__DIR__);

$result = $tell->run(
    TellRequest::prompt('Summarize the release risks')
        ->connection('deepseek')
        ->model('deepseek-v4-flash'),
);

foreach ($tell->runStream(
    TellRequest::prompt('Investigate and report progress')
        ->onEvent(fn ($event) => $logger->info($event->kind, $event->metadata)),
) as $progress) {
    $reportProgress($progress->stepCount(), $progress->usage());
}

Consume the stream to completion before reading its TellResult via Generator::getReturn(). A durable streamed turn publishes only after this successful completion; abandoning the generator leaves its selected ref unchanged.

Use workspace handles for intentional durable work. They return SDK values, never Arena infrastructure or storage records:

$workspace = $tell->workspace();
$workspace->initialize();

$conversation = $tell->conversation('release-review');
$conversation->send(TellRequest::prompt('Record the decision.'));
$history = $conversation->history(limit: 10);
// Moves only this selector to empty; immutable history remains.
$conversation->clear();

TellEventEnvelope::toArray() returns the same safe tell.event.v1 projection used by NDJSON and default traces. The envelope is all a listener gets: it holds scalars only, so there is no raw framework event behind it to serialize by accident. An application that deliberately needs the original typed Agent event takes TellRuntime::run($request, $prepareLoop) and wiretaps the loop, where the event arrives as its own class rather than an untyped object.

Deterministic SDK tests

Applications can test Tell orchestration without HTTP calls or real provider credentials. The convenience API scripts final responses:

$result = Tell::testing($temporaryProject, 'scripted answer')->run(
    TellRequest::prompt('Exercise the integration.'),
);

Use TellTestFactory::steps() with Agents' ScenarioStep values for multi-step tool, usage, and terminal-failure scenarios. This keeps Tell's request compilation, tools, policies, events, workspaces, and persistence real; only provider inference is replaced. The factory writes isolated test state under $temporaryProject/.tell-testing, so callers should supply a temporary project and own its cleanup.

Host-scoped shell jobs

Applications that need a background command can opt into a separate shell-job host. It is not booted by Tell::open(), the CLI, or the one-run protocol. Denial is the default, so the embedding boundary must explicitly supply an approval policy:

use Cognesy\Tell\Data\TellShellJobRequest;
use Cognesy\Tell\Shell\TellShellJobApprovals;
use Cognesy\Tell\Shell\TellShellJobHost;

$host = TellShellJobHost::shellJobs(
    project: __DIR__,
    approval: TellShellJobApprovals::allowAll(),
)->boot();

try {
    $job = $host->jobs()->start(
        TellShellJobRequest::command('php -S 127.0.0.1:8080')
            ->forMilliseconds(30_000),
    );
    $page = $host->jobs()->read($job->id, after: 0);
    $finished = $host->jobs()->cancel($job->id);
} finally {
    $host->dispose();
}

Jobs may outlive start() but never their shell-job host or PHP process. Host policy bounds their project-local working directory, concurrency, lifetime, retained output, each cursored read, and cancellation grace. Public callers get immutable snapshots and output chunks—not process, pipe, Cordis context, or fiber handles. tell.shell-job.event.v1 observations are distinct from agent execution events and never contain commands, environment values, or output.

External one-run protocol

Non-PHP supervisors can execute the same public request model through a small process boundary:

request='{"schema":"tell.agent.request.v1","id":"job-42",'
request+='"prompt":"Review the release","mode":"stateless"}'
printf '%s\n' "$request" | tell agent --rpc --dir /path/to/project

The command reads exactly one JSON object (one line, at most 1 MiB) from stdin. The request schema is tell.agent.request.v1:

{
  "schema": "tell.agent.request.v1",
  "id": "job-42",
  "prompt": "Review the release",
  "agent": "default",
  "connection": "deepseek",
  "model": "deepseek-v4-flash",
  "reasoningEffort": "medium",
  "mode": "stateless",
  "tools": ["read_file"],
  "maxSteps": 5,
  "policy": {
    "maxRetries": 1,
    "timeoutMs": 30000,
    "maxOutputChars": 20000,
    "maxToolOutputChars": 4000,
    "maxToolCalls": 8
  }
}

Only schema, id, and prompt are required. mode is stateless by default and may also be durable or transient; durable/transient requests can select one session or branch. Unknown fields and schema versions are rejected before inference. The boundary deliberately accepts no DSN, raw provider options, credentials, headers, or pre-supplied ask_user answers.

Stdout contains only newline-delimited tell.agent.frame.v1 objects. Sequence numbers start at one and increase monotonically. A run emits zero or more progress frames followed by exactly one terminal frame:

Terminal type Meaning Exit status
result completed run with a bounded answer and usage 0
error invalid request, stopped budget, or failed run 2 for invalid input; otherwise 1
cancelled cooperative caller/SIGINT cancellation 130

Each frame is capped at 1 MiB; terminal answers are UTF-8 safely capped at 200,000 bytes and carry answerTruncated. Prompts, tool arguments/results, provider payloads, exception messages, credentials, and absolute workspace paths are not serialized. Bounded human diagnostics are written to stderr.

Compatibility is schema-versioned, not inferred from the Tell package version. Within v1, existing fields and meanings remain stable and new optional fields may be added. Controllers must ignore unknown response fields but should reject an unknown schema. Any breaking request or frame change requires a new schema identifier and parallel support during a documented migration window. This is a one-run protocol—not a resident daemon, bidirectional JSON-RPC session, or pause/resume API. Cancellation uses the process signal/cooperative hook.

Non-interactive questions

Tell never opens a terminal prompt. An agent can call its Tell-owned ask_user tool only to consume an answer supplied before execution. Provide ordered answers with repeatable --answer, or use one UTF-8 JSON array source:

tell --answer yes --answer production "run the release check"
tell --answers-file answers.json "run the release check"
printf '%s' '[{"id":"target","value":"production"}]' | \
  tell --answers-stdin "run the release check"

An array item is either a string (the next ordered answer) or an object with id and value. IDs select exactly one matching ask_user call; an answer outside declared choices, a missing answer, malformed input, duplicate IDs, or an oversized value fails immediately with a typed tool result. Tell accepts at most 32 answers of 8192 bytes each. Extra answers are reported only as a count. Answers are redacted from normalized events and default traces. A completed durable turn keeps the semantic tool result in its canonical history; a transient turn does not publish it.

PHP callers provide the same bounded queue explicitly:

use Cognesy\Tell\Capability\AskUser\TellAnswerQueue;

$request = TellRequest::prompt('Run the release check')
    ->withAnswers(new TellAnswerQueue([
        ['id' => 'target', 'value' => 'production', 'source' => 'cli'],
    ]));

Durable project workspaces

Durable project history is opt-in. Initialize it once from the project root:

tell init

This creates a private, versioned .tell/arena in the project. On later turns Tell discovers the nearest initialized workspace, compiles the selected canonical history before the new prompt, and publishes a new immutable turn only after a completed execution. Projects without .tell/ keep the normal stateless behavior.

The default durable conversation is main. --session NAME selects an independent named conversation stored in the same canonical Arena. Named sessions require an initialized workspace; Tell has no second session store or fallback persistence path.

Tell branches are immutable-head user references for planning independent lines of work. Creation shares the existing canonical head; it never copies canonical objects. Each workspace starts on main; use checkout to make a different branch current, or pass --branch to select one invocation without changing the workspace selection:

tell branch list --json
tell branch create review             # points at the current main head
tell branch create followup --from review
tell branch create scratch --empty
tell branch show review --json
tell checkout review
tell --branch main "compare the original plan"

Branch names are 1-64 lowercase ASCII characters, begin with a letter, and may otherwise contain letters, digits, and hyphens. main, internal-*, session-*, and agent-* are reserved; uppercase and Unicode are rejected to avoid cross-filesystem case ambiguity. list and show only verify local refs and canonical objects—no inference or writes occur. create atomically writes only its new branch ref plus immutable creation provenance. Reset, checkout, merge, rebase, deletion, and garbage collection are intentionally not part of the branch command.

tell reset moves only one selected branch ref backwards, either by a bounded number of parent links or to a verified reachable canonical ancestor. It never deletes immutable objects, and deliberately has no public reflog; make a recovery branch before moving a head if you need a durable return point:

tell branch create before-reset --from review
tell reset --branch review --steps 1 --json
tell reset --branch review --to <ancestor-hash> --json

The reset succeeds only if the selected head has not changed since validation; a concurrent update fails safely rather than overwriting another turn.

PHP consumers can inspect any branch without changing the current checkout and can pin a verified immutable head or root:

$review = $tell->workspace()->branch('review');
$frozen = $review->pin();

$reviewHistory = $review->history(); // follows the named branch ref
$frozenHistory = $frozen->history(); // remains fixed after branch changes
$sameSnapshot = $tell->workspace()->ref($frozen->hash());

TellBranch and TellRef are read-only. Their bounded history(), transcript(), and context() projections use the same canonical validation and preview rules as conversation inspection. Mutation remains explicit on workspace()->branches().

Each branch may also keep secret-free runtime intent. It is versioned and atomically updated, so configuration for one branch cannot modify another:

tell config show --branch review --json
tell config set connection '"deepseek"' --if-version 0 --branch review
tell config set model '"deepseek-v4-flash"' --if-version 1 --branch review
tell config set reasoningEffort '"medium"' --if-version 2 --branch review
tell config set output '"human"' --if-version 3 --branch review
tell --branch review --reasoning-effort low "review the release"
tell config effective --branch review --json

Allowed keys are connection, model, reasoningEffort, output, tools, maxRetries, timeoutMs, maxOutputChars, maxToolOutputChars, maxToolCalls, maxSpillBytes, and maxStubBytes. output selects the default turn format for the branch and accepts the same values as --output. Values are labels, model names, tool profiles, and bounded policy values only: Tell rejects credentials, tokens, headers, raw environment values, and DSNs with embedded credentials. New branches copy source intent by value and later edits remain independent. Explicit connection, model, reasoning-effort, output, and tool flags take precedence over branch intent. PHP callers select the typed value with TellRequest::reasoningEffort(ReasoningEffort::Low) after importing Cognesy\Polyglot\Inference\Reasoning\ReasoningEffort; supported values are minimal, low, medium, high, xhigh, and max. Tell asks Polyglot to validate the selected provider, protocol, and model, then Polyglot translates supported intent at the request-body boundary. Tell therefore keeps no provider-specific reasoning table or raw-option mapper of its own. effective identifies the source of each branch/bundled value; it never resolves or displays credential material.

Providers and models

Tell reads connection presets and declared driver capability metadata from Polyglot; it does not keep a second provider table. These inspection commands need neither credentials nor network access:

tell providers --json
tell providers --fields=connection,provider,defaultModel,source --json
tell models deepseek --json
tell models qwen --json
tell config effective --branch review --json

providers lists the resolved connection precedence and its preset default model. models accepts either a provider or a connection name and lists only models explicitly declared by those presets. Full provider rows include known context and tool/structured-output metadata with source provenance. Metadata Polyglot does not declare—such as vision, thinking, or a full remote model catalogue—is returned as explicitly unknown with a reason, never inferred from model names. config effective reports the selected connection/model and their sources without resolving or displaying an API key.

Coding tools and direct dispatch

The default Tell agent exposes one bounded implementation for each canonical coding operation: read_file, write_file, apply_patch, and shell. Existing read, write, edit, and bash names remain compatibility aliases over those same operations and policy. apply_patch validates all hunks before writing, confines paths to the project, and never falls back to an arbitrary shell command.

Shell agents can invoke the exact same registered tool without inference or conversation publication:

tell tool read_file '{"path":"README.md"}' --json
tell tool apply_patch --input-file patch.json --json
printf '%s' '{"command":"printf ready"}' | tell tool shell --stdin --json

Direct dispatch validates one strict JSON argument object, applies the selected branch's tool and execution policy, and returns a bounded structured result. It may perform the named tool's declared file/shell side effect, but it never runs a model or appends a Tell turn. Event output uses the same redacted, versioned envelope as agent execution.

Bounded child delegation

The built-in spawn_subagent tool gives a Tell agent one sequential delegated run. The tool creates a private agent-* branch before it starts, records non-secret policy/configuration provenance, and returns a bounded child result to the parent on successful completion. context: "fork" starts at the parent's captured canonical head; context: "fresh" starts from empty context. Later parent changes cannot alter either start point.

Child branches use the same effective policy, tool registry, cancellation, and redacted events as their parent. They can be listed or inspected with tell branch show, tell history --branch, or tell transcript --branch, but cannot be selected, reset, or written as normal user branches. Delegation is depth-one and sequential: a child cannot create a grandchild, and it has no authority to write parent or sibling refs. A failed, cancelled, or stale child publication leaves the parent ref unchanged; completed child history remains inspectable on its own branch.

Execution budgets

Every Tell execution has finite policy defaults: zero provider retries, a 30s wall deadline, 200,000 total model-output bytes, 40,000 bytes retained from one tool result, 100 tool calls, a 200,000-byte spill ceiling, and a 2,000-byte spill stub. Override one invocation without persisting it:

tell --max-retries 2 --timeout-ms 60000 --max-output-chars 100000 \
  --max-tool-output-chars 12000 --max-tool-calls 20 "investigate the failure"

SIGINT is cooperative: Tell stops at the next public agent boundary, emits one non-success terminal event, and does not publish a durable branch head for the interrupted turn. This requires PHP's pcntl signal support; verbose CLI output reports when it is unavailable. SDK callers can instead provide their own public Agents cancellation source to Tell::open() for deterministic programmatic cancellation.

The same limits are available through TellRequest (maxRetries(), timeoutMs(), maxOutputChars(), maxToolOutputChars(), maxToolCalls(), maxSpillBytes(), and maxStubBytes()). Policy precedence is CLI/SDK override, branch config, project defaults, user defaults, then bundled values. Project defaults live at .tell/arena/config/defaults.json; user defaults live at ~/.tell/config/execution-defaults.json. Both are strict, secret-free JSON:

{"schema":"tell.execution-defaults.v1","values":{"timeoutMs":60000,"maxToolCalls":20}}

Tell rejects invalid, zero, negative, or over-limit values before inference. An exhausted deadline, output, or tool-call limit stops the turn; tool-result truncation is explicit and UTF-8 safe. An incomplete stopped turn never moves a durable ref, while a completed answer exactly at a limit may publish.

Spilled tool output

A tool result larger than maxToolOutputChars is not cut down any more. Tell writes the whole result to a content-addressed blob and hands the step a stub in its place:

[tool output: 4,812 lines, 312.4 KB — stored at ~/.tell/runtime/blobs/7f2c…/ab/12cd34ef56a789.txt]
  PASS  Tests\Feature\RenderingTest
  …as much of the head as the stub budget buys…
Continue: read("~/.tell/runtime/blobs/7f2c…/ab/12cd34ef56a789.txt", offset=20, limit=200)

The head preview answers most questions without a read at all, and the read call is there for the ones it does not. The blob keeps the bytes the older head/tail window used to discard, so a model that needs line 900 of a test run can still reach it. Identical results share one blob.

Blobs live in Tell's own storage, not in the project they describe. A turn run outside an initialized workspace persists nothing, and that has to stay true of spilling too - a blob directory left behind in whatever folder tell was run from would break it, and did. The store is ~/.tell/runtime/blobs/, one subdirectory per project path so two projects never share a store, created 0700 on first write and not before. The coding tools are handed the store as an explicitly readable path, so the stub's read hint resolves without the tools reaching it by accident.

Within a project's store a blob is sharded on the first two characters of its own name, git-style: blobs/<project>/ab/12cd34ef56a789.txt. Tell never enumerates the store - a stub names the exact path, and that lookup is as fast flat as sharded - so the fanout is bounded for everything else that walks a directory: a shell glob, a backup pass, a person opening the folder.

maxStubBytes is what reaches the conversation, and maxSpillBytes is only what reaches the disk: however large the blob, the step sees the stub. It is also the only limit that applies to the stub, which is emitted whole regardless of maxToolOutputChars, because that limit is what the stub answers rather than something it is subject to. Lower the budget for a shorter head, or set it to 0 for a header and a read hint with no preview. The header and the read hint are never dropped - a stub cut short would name a file and lose the instruction for opening it.

maxSpillBytes bounds what one command may cost in memory and on disk, since spilling raises the shell tool's own capture caps to it. The default holds a long test run or build log; the ceiling is 5,000,000.

A result that is not text - a NUL byte or invalid UTF-8 in its first 8 KB - is stored under a .bin name with no preview and no read hint, because its bytes would be noise in the conversation and the read tool refuses a binary file. The stub says what it is, how big, and where, and leaves inspecting it to a shell command.

This writes raw tool output to disk, and it is on by default. Everything a tool printed - file contents, command output, whatever the environment happened to include - lands under ~/.tell/runtime/blobs/ and stays there until something removes it. That is a deliberate change of posture: Tell's traces are payload-free by default and its normalized events carry no payloads, and blobs carry the payload in full. The store is created 0700 and never leaves the machine, but it is a plain readable file. Nothing prunes it, by design: Tell has no retention policy for blobs any more than it has one for traces or sessions, and reclaiming the space is yours to do. Set maxSpillBytes to 0 - per invocation with --max-spill-bytes 0, per branch with tell config set maxSpillBytes 0, or for a project or user in the defaults files above - to turn spilling off and get the previous head/tail truncation instead.

Spilling raises the shell tool's own capture caps to the spill ceiling, because a result the tool has already truncated is not one Tell can preserve.

Use the workspace commands to inspect and manage the selected conversation:

tell history --json                 # bounded, oldest-first turn summaries
tell transcript --full --json       # ordered semantic messages and tool traces
tell context --json                 # compiled next-turn context, without inference
tell compact "keep release decisions" # explicit, provenance-linked summary
tell clear --json                   # make the selected ref empty; retain objects

history, transcript, and context are read-only: they do not resolve a provider, build an agent loop, run tools, or change state. Their default output is bounded; pass --full only when complete canonical content is required. compact uses the configured inference connection and replaces the selected ref with a concise immutable summary linked to the prior head. clear moves only the selected ref to empty: it does not delete immutable records, traces, or configuration.

For a one-off experiment that may use the ordinary workspace context and tools but must not change any conversation or session state, use --transient:

tell --transient "compare this approach without recording it"
tell --session review-1 --transient "inspect the current review safely"

Transient execution compiles the same selected history as a durable turn but never writes canonical objects or refs, saves sessions, or changes configuration. It stays stateless outside a workspace. Text output states that nothing was persisted; JSON and TOON include execution.mode: transient and execution.durable: false; events retain the same normalized lifecycle envelope. Execution traces remain external observations under the normal trace privacy policy.

execution.mode reports what the turn actually persisted, so it has three values and is not a restatement of --transient:

execution.mode execution.durable Turn
durable true Published an immutable arena turn, or saved a named session.
transient false Ran with the workspace context but deliberately wrote no conversation or session state.
stateless false Ran outside any initialized workspace with no named session, so there was nothing to publish.

stateless is the default outside a .tell/ project. Consumers that branch on execution.mode must accept all three values; execution.durable remains the single boolean answer to whether conversation state was written.

Arena records contain semantic messages and tool-call/result relationships only. Provider requests and responses, credentials, headers, usage, timing, rendering data, traces, and absolute paths are not part of Arena object hashes. Immutable records can remain after a failed compare-and-swap publication, compaction, or clear; Tell does not run garbage collection.

Watching a turn happen

Two stderr channels report a turn in progress. -v writes it for a reader; --debug writes it for a program. Both compose with whichever --output format stdout was asked for, and neither can be combined with --quiet.

-v shows each step, each tool call with the argument that matters, and each result:

● step 1
  ▸ shell [check the suite]
    │ vendor/bin/pest packages/tell
  ✔ shell 812ms
    │ Tests:    342 passed (2033 assertions)
● step 2
  ▸ write `notes.md` (184 bytes)
    │ # Findings
    ⋯ 6 more lines
  ✔ write 3ms
● completed 2 steps, 4210 in / 318 out tokens

Known tools show what they are doing rather than their JSON arguments: a shell call shows its command, a file call its path, a write its size, an edit the lines it replaces. Anything else falls back to name plus arguments. Bodies are previewed at twelve lines and elision is stated, not silent; -vvv stops abridging.

--debug writes one bracketed key=value line per event instead:

[step.start] step=1 messages=14 tools=8
[tool.start] name=shell step=1 args={"command":"vendor/bin/pest packages/tell"}
[tool.complete] name=shell status=ok step=1 duration=812ms result={"success":true,…}
[step.complete] step=1 toolCalls=yes errors=0 in=4210 out=318 finish=tool_calls
[execution.complete] status=completed steps=2 in=4210 out=318

Kinds and keys are the ones from the normalized tell.event.v1 contract, so the lines read against the same vocabulary as --output=events. status is failed whenever the call failed or the tool returned its own failure envelope. Payload values are always valid JSON and bounded to 512 bytes; an excerpt is emitted as a JSON string and carries a companion argsBytes or resultBytes giving the real size, so the presence of that key is what says the value is an excerpt.

--output=human asks for a reader at a terminal, so with neither flag it gets a third thing: one self-erasing line saying what the turn is doing.

⠹ step 2 · shell: check the suite  14s

It names the step, the running tool and its salient argument, and how long the turn has been going. The frames advance on a clock rather than on events, because a PHP turn spends most of its wall clock blocked inside the inference request where nothing in-process can run; the drawing happens in a forked child that is killed and reaped when the turn ends. Where that is unavailable the line still reports status, it just advances when something happens. A tool that asks the person a question takes the terminal back for the duration.

The line exists only on a terminal. Redirect or pipe stderr and it is not written at all - a line that erases itself is noise in a file - and -v, --debug, and --quiet each supersede it.

Without any of this, toon, text, and redirected human output keep the bare [inference.start] step=N heartbeat they have always written.

Whenever a channel wrote anything, a blank line follows it before the answer, so progress never runs straight into the result. That separator goes on stderr along with the progress that earned it, so a piped or redirected stdout is unchanged.

Both channels show tool arguments and results, which no other Tell surface does. That is what asking for them means, and it is why they exist only for the invocation that asked: they are never persisted, never enter the normalized tell.event.v1 stream, and never reach an execution trace file.

Local storage and execution traces

Tell keeps its local concerns under one explicit runtime home. Set TELL_HOME to override it; otherwise Tell uses ~/.tell (%USERPROFILE%\.tell on Windows):

~/.tell/
├── config/
│   ├── tell.json
│   ├── credentials.env
│   ├── connections/
│   └── agents/
├── runtime/
│   └── sessions/
└── logs/
    ├── executions/YYYY-MM-DD/<execution-id>.jsonl
    └── sessions/<session-id>-<stable-hash>.jsonl

Stateless turns receive one trace file per execution. Every named conversation has a separate session trace; later turns append to the same file. JSONL writes use an exclusive file lock, so independently running sessions never share a target and concurrent appends cannot corrupt a line. Tell creates runtime and log directories with private permissions and trace files with mode 0600 on platforms that support POSIX permissions.

Credentials and connections

Provider credentials resolve in a fixed order:

  1. the process environment,
  2. the selected workspace's .env,
  3. ~/.tell/config/credentials.env.

The Tell credential store is optional and created only by an explicit auth set. It is written atomically with mode 0600 on POSIX systems. Values are accepted only through stdin and are never included in tell, describe, auth status, traces, or errors:

tell auth status openai --json
printf '%s' "$OPENAI_API_KEY" | tell auth set openai --stdin
tell auth remove openai

Tell never copies ambient credentials into its store. auth status reports only whether a variable is configured and which layer supplied it. A missing credential for a remote connection fails before inference with a safe action. Local connections such as Ollama do not require a key.

Put user connection overlays in ~/.tell/config/connections/<name>.yaml. Workspace files under config/llm/presets/ take precedence over user files, which take precedence over bundled presets. ${VARIABLE} placeholders in all of them use the credential order above. Raw keys do not belong in tell.json. The resolver is injected through Instructor Config's CanResolveSecrets contract, leaving room for an OS-keychain source without changing connection files or the data-plane runtime.

Each default trace line is the same payload-free tell.event.v1 envelope as the NDJSON renderer: schema, stable kind, sequence, execution ID, selected branch/session, bounded public metadata, and one terminal status. Prompts, tool arguments/results, exception details, state snapshots, and provider payloads are omitted by default. includePayloads: true adds a separate, sanitized trace-only payload field; common credential fields remain redacted. Put this optional configuration in ~/.tell/config/tell.json:

{
  "schema": "tell.config.v1",
  "observability": {
    "executionTraces": true,
    "includePayloads": false,
    "maxStringLength": 4096
  }
}

Unknown configuration keys and invalid values fail loudly before inference. Trace write failures are deliberately fail-open: the turn still runs and its normal stdout contract is unchanged. Tell does not rotate or upload logs; the directory is an external observability boundary for tail, jq, collectors, and operator-managed retention.

Errors are structured data on stdout. Exit 0 means success, 1 means the requested execution failed, and 2 means invalid usage. Unknown flags fail loudly and include valid flags plus a command-specific help action.

Tell deliberately does not install ambient editor/session hooks or inject a Tell-usage skill into agents. AXI is applied to the CLI contract only; adding self-integration here would create a recursive Tell-teaches-Tell layer with no workspace-state benefit.

tell planes exposes the logical operational map for Tell's own runtime boundary. Agent turns are data-plane work; effective profile/tool resolution is control-plane work; credential and session lifecycle plus agent inventory are management-plane work. The data plane receives an already resolved LLM configuration and owns only its selected trace target, and a trace sink failure does not block inference. --full adds owned state, cross-plane inputs/outputs, authority, and degraded behavior. These roles stay collocated in one binary—they are not three parallel command trees or services.