# Custom Provider Profiles
`CliSubprocessCore.ProviderProfile` is the extension point for providers that
are not shipped with the package. A custom profile owns command construction,
parser state, stdout/stderr decoding, terminal exit handling, and any
transport overrides needed by that CLI.
## When To Add A Profile
Add a custom profile when:
- the provider CLI already exists and should run through the common runtime
- you need normalized `CliSubprocessCore.Event` values instead of provider-only
payloads
- downstream code should not duplicate command-building or JSONL parsing logic
If you only need raw subprocess ownership, use `CliSubprocessCore.RawSession`
or `ExecutionPlane.Process.Transport` directly for the covered local
session-bearing lane and skip the profile layer.
The packaging rule for this layer is:
- first-party common profiles stay built into `cli_subprocess_core`
- third-party/common custom profiles belong in external packages that implement
`CliSubprocessCore.ProviderProfile`
- those external packages register explicitly at runtime or preload
intentionally at registry boot
## Behaviour Surface
Every profile implements:
```elixir
@callback id() :: atom()
@callback capabilities() :: [atom()]
@callback build_invocation(keyword()) ::
{:ok, CliSubprocessCore.Command.t()} | {:error, term()}
@callback init_parser_state(keyword()) :: term()
@callback decode_stdout(binary(), term()) ::
{[CliSubprocessCore.Event.t()], term()}
@callback decode_stderr(binary(), term()) ::
{[CliSubprocessCore.Event.t()], term()}
@callback handle_exit(term(), term()) ::
{[CliSubprocessCore.Event.t()], term()}
@callback transport_options(keyword()) :: keyword()
```
The contract is documented in more detail in
`guides/provider-profile-contract.md`.
## Minimal Example
```elixir
defmodule MyApp.ProviderProfiles.Example do
@behaviour CliSubprocessCore.ProviderProfile
alias CliSubprocessCore.{Command, Event, Payload}
alias CliSubprocessCore, as: Core
@impl true
def id, do: :example
@impl true
def capabilities, do: [:interrupt, :streaming]
@impl true
def build_invocation(opts) do
prompt = Keyword.fetch!(opts, :prompt)
{:ok, Command.new("example-cli", ["run", "--jsonl", prompt])}
end
@impl true
def init_parser_state(_opts) do
%{provider_session_id: nil}
end
@impl true
def decode_stdout(line, state) do
payload = Payload.AssistantDelta.new(content: line)
event =
Event.new(:assistant_delta,
provider: id(),
payload: payload,
provider_session_id: state.provider_session_id
)
{[event], state}
end
@impl true
def decode_stderr(chunk, state) do
payload = Payload.Stderr.new(content: chunk)
event = Event.new(:stderr, provider: id(), payload: payload)
{[event], state}
end
@impl true
def handle_exit(reason, state) do
exit = Core.ProcessExit.from_reason(reason)
{kind, payload} =
if Core.ProcessExit.successful?(exit) do
{:result,
Payload.Result.new(
status: :completed,
stop_reason: normalize_stop_reason(exit.reason),
output: %{code: exit.code, signal: exit.signal}
)}
else
{:error,
Payload.Error.new(
message: "CLI exited with code #{exit.code}",
code: normalize_error_code(exit.reason)
)}
end
event = Event.new(kind, provider: id(), payload: payload)
{[event], state}
end
@impl true
def transport_options(_opts), do: []
defp normalize_stop_reason(reason) when is_binary(reason), do: reason
defp normalize_stop_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
defp normalize_stop_reason(reason), do: inspect(reason)
defp normalize_error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
defp normalize_error_code(reason) when is_binary(reason), do: reason
defp normalize_error_code(reason), do: inspect(reason)
end
```
## Command Construction
`build_invocation/1` should return a fully validated
`CliSubprocessCore.Command`:
- resolve CLI-specific flags and defaults here
- keep session-level reserved keys out of the profile logic
- return `{:error, term()}` for missing required inputs instead of raising
- put provider-specific environment and cwd handling into the command struct
Use `CliSubprocessCore.ProviderProfile.validate_invocation/1` when testing the
result.
## Parser State
`init_parser_state/1` should return exactly the state your parser needs:
- provider session id or conversation id
- partial result tracking
- flags such as `result_emitted?`
- any CLI-specific decode context
The state returned from `decode_stdout/2`, `decode_stderr/2`, and
`handle_exit/2` is fed back into subsequent callbacks by
`CliSubprocessCore.Session`.
## Emitting Normalized Events
Profiles should emit `CliSubprocessCore.Event` structs containing
`CliSubprocessCore.Payload.*` structs. That keeps the shared runtime vocabulary
stable across SDKs and higher-level orchestration layers.
Common patterns:
- map streamed text to `Payload.AssistantDelta`
- map completed messages to `Payload.AssistantMessage`
- map stderr chunks to `Payload.Stderr`
- map terminal success to `Payload.Result`
- map non-zero exits and parse failures to `Payload.Error`
- emit `provider_session_id` whenever the CLI exposes one
The built-in profiles also normalize terminal exits in a specific way:
- success emits exactly one `:result` with `status: :completed`
- failure emits exactly one `:error`
- `exit.reason` is normalized before it becomes a public `stop_reason` or
error `code`, because the runtime value may be an atom or tuple rather than
a display-friendly string
The session layer will assign the final `id`, `sequence`, timestamp, provider,
and merged metadata when it normalizes and dispatches each event.
## Transport Overrides
`transport_options/1` is the profile hook for raw transport tuning. Use it for:
- larger stdout buffers when the provider emits large JSONL lines
- stderr callbacks used by CLI-specific diagnostics
- custom headless timeouts
- lazy startup or other transport-level behavior
Do not put `:command`, `:args`, `:cwd`, `:env`, `:subscriber`, or `:event_tag`
in the returned keyword list. `CliSubprocessCore.Session` owns those values.
## Registration
Register the profile explicitly:
```elixir
:ok = CliSubprocessCore.ProviderRegistry.register(MyApp.ProviderProfiles.Example)
```
Or add it to the app config so the default registry boots with it:
```elixir
config :cli_subprocess_core,
built_in_profile_modules: [MyApp.ProviderProfiles.Example]
```
That preload hook only affects the local registry boot list. It does not turn
your external package into a first-party built-in profile.
Then start a session with either `provider: :example` or
`profile: MyApp.ProviderProfiles.Example`.
## Recommended Test Matrix
Every custom profile should cover:
- command-building unit tests
- parser tests for stdout fixture lines
- parser tests for stderr chunks
- exit handling for success, interrupt, and non-zero exits
- session integration tests using a mock CLI script
See `guides/testing-and-conformance.md` for the full conformance checklist.