Fountain Elixir SDK
The official Elixir client for Fountain. It covers agent runs and follow-ups, reconnecting event streams, permission requests, resources and secrets, teammates and schedules, connections, conversation history, and sandbox lifecycle/files.
Installation
Add fountain_sdk to mix.exs:
def deps do
[{:fountain_sdk, "~> 0.4.0"}]
end
Elixir 1.15 or newer is required.
Authentication
The client resolves credentials the same way as the Fountain CLI:
- Explicit
:api_keyand:base_urloptions FOUNTAIN_API_KEY(or sandbox-scopedFOUNTAIN_TOKEN) andFOUNTAIN_BASE_URL- The selected profile in
~/.fountain/credentials - The hosted Fountain endpoint
client = Fountain.new(api_key: System.fetch_env!("FOUNTAIN_API_KEY"))
When code runs inside a Fountain sandbox, FOUNTAIN_CONVERSATION_ID is automatically sent as parent attribution. HTTPS uses peer and hostname verification with the Erlang/OTP CA store.
Run an agent
Pass sandbox_api_access: "none", sandbox_mode: "ephemeral" to Fountain.run/3
to start without an owner callback credential in the sandbox. Omitting
:sandbox_api_access inherits a resumed channel's setting; new conversations
default to "owner".
Fountain.run/3 starts work immediately. The handle supports one completion wait and any number of event consumers without making a second API request.
client = Fountain.new()
run =
Fountain.run(client, "Upgrade us to Phoenix 1.8 and open a PR",
agent: "reposage",
vault: "github-bot"
)
{:ok, result} = Fountain.Run.await(run)
IO.puts(result.text)
IO.inspect(result.tools_used)
Set timeout: milliseconds on Fountain.run/3, Fountain.Conversation.send/3, or Fountain.Team.message/4 to stop the SDK waiting. A timeout does not interrupt the remote turn; the returned %Fountain.Error{kind: :timeout} contains conversation_id and partial_text so it can be resumed.
Stream all derived and raw events, or only answer text:
for event <- Fountain.Run.stream(run), do: IO.inspect(event)
for text <- Fountain.Run.text_stream(run), do: IO.write(text)
Run events use idiomatic atom keys and :conversation, :turn_start, :text, :thinking, :tool, :permission, :block, :event, and :turn_end types. Raw API payloads remain string-keyed maps.
Permissions and control
for %{type: :permission, request: request} <- Fountain.Run.stream(run) do
allow = Enum.find(request.options, &(&1["kind"] == "allow_once"))
if allow, do: Fountain.Run.answer(run, request.request_id, allow.option_id)
end
Fountain.Run.interrupt(run) # stop the turn; keep the sandbox
Fountain.Run.terminate(run) # tear the sandbox down
Fountain.Run.cancel(run) # stop only this SDK wait
Follow up and inspect conversations
conversation = Fountain.resume(client, result.conversation_id)
follow_up = Fountain.Conversation.send(conversation, "Now add regression tests")
{:ok, next_result} = Fountain.Run.await(follow_up)
{:ok, history} = Fountain.Conversation.history(conversation, streams: [:acp])
{:ok, turns} = Fountain.Conversation.turns(conversation)
:ok = Fountain.Conversation.mark_read(conversation)
history/2 drains every page and requests server-parsed blocks. events/2 returns the raw reconnecting SSE enumerable. Follow-ups discover the end of a cold conversation's stage feed before posting, preventing old output from being replayed as the new answer.
Conversation handles also provide get/1, status/1, tree/1, event_page/3, answer/3, interrupt/1, terminate/1, and delete/1.
Resources and secrets
The client exposes resource handles directly:
{:ok, agents} = Fountain.Agents.list(client.agents)
{:ok, agent} = Fountain.Agents.get(client.agents, "reposage")
{:ok, created} = Fountain.Environments.create(client.environments, environment_definition)
:ok = Fountain.Secrets.delete(client.environments.secrets, "production", "OLD_TOKEN")
{:ok, _} = Fountain.Secrets.set(client.vaults.secrets, "github-bot", "GITHUB_TOKEN", token)
Fountain.Agents, Fountain.Environments, and Fountain.Vaults implement list, get, create, update, and delete. Names resolve case-insensitively, then by unique prefix; UUIDs skip a listing. Resource payloads use the API's string keys so definitions can be shared with REST and fountain.yml.
Teammates and schedules
{:ok, teammates} = Fountain.Team.list(client.team)
run = Fountain.Team.message(client.team, "reviewer", "Review the latest PR")
{:ok, schedule} =
Fountain.TeamSchedules.create(client.team.schedules, "reviewer", %{
"cron" => "0 9 * * 1-5",
"prompt" => "Review open pull requests"
})
Fountain.Team includes list, get, add, remove, rename, message, conversation, history, fresh_conversation, and stream. Fountain.TeamSchedules includes list, get, create, update, delete, and run.
Connections and sandboxes
Provider connections are available at client.connections; provider definitions are at client.connections.providers.
{:ok, connections} = Fountain.Connections.list(client.connections)
{:ok, providers} = Fountain.ConnectionProviders.list(client.connections.providers)
{:ok, sandboxes} = Fountain.sandboxes(client, status: ["ready", "suspended"])
{:ok, listing} = Fountain.sandbox_files(client, sandbox_id, "src")
{:ok, file} = Fountain.sandbox_file(client, sandbox_id, "mix.exs", max_bytes: 64_000)
{:ok, diff} = Fountain.sandbox_diff(client, sandbox_id, staged: true)
:ok = Fountain.reset_sandbox(client, sandbox_id)
Connections support list, get, and delete. Providers support list, get, create, update, delete, and MCP discover.
Errors and raw requests
All non-bang public calls return {:ok, value}, :ok, or {:error, %Fountain.Error{}}. Errors expose kind, HTTP status, server code, parsed body, and retry_after. Helpers include Fountain.Error.retryable?/1 and field_errors/1.
case Fountain.request(client, "GET", "/api/new-endpoint") do
{:ok, body} -> body
{:error, %Fountain.Error{kind: :rate_limit, retry_after: seconds}} -> {:retry, seconds}
end
Absolute URLs are accepted only when they have the configured Fountain origin, preventing bearer credentials from being sent cross-origin.
Process ownership
A client resolver cache, conversation cursor, and run server are lightweight process-owned state. Create and use handles from a long-lived process (such as a GenServer) when sharing them. A run and its active HTTP/SSE work are cleaned up when the process that created it exits.
License
Apache-2.0. See LICENSE.
Credit error migration (0.3.0)
Replace %Fountain.Error{kind: :subscription_required} patterns with
%Fountain.Error{kind: :insufficient_credits}. Read Fountain.Error.upgrade_url(error)
to offer the credit-purchase page; do not retry a 402 without adding credit.
For billing error handling, use Fountain v0.13.0 or newer.
v0.13.0 is the first
release containing the credit-only server contract (c3349343).
insufficient_credits and a generic HTTP 402 identify the credit gate.
subscription_required has no special mapping; it follows the HTTP status.
The response still exposes its original code and purchase URL.
API-shaped launches
Fountain.run_request(client, %{
"agent_id" => agent_id,
"prompt" => "Review the repository",
"labels" => %{"source" => "nightly"},
"sandbox_api_access" => "none"
}, timeout: 120_000, collect_events: true)
|> Fountain.Run.await()
The request uses string keys and IDs directly; atom keys at its top level are
refused rather than converted or merged. Explicit nil, false, zero and empty
values survive; omitted keys remain omitted. Local options stay in the third
argument. No names are resolved and no legacy run options are merged into the
request; existing Fountain.run/3 calls keep working. A run requires a non-empty
prompt and cannot queue. Use the HTTP client for promptless or queued creation.
With channel_id, run_request follows turn 1 when the server creates a
conversation, including fresh launches. When the server resumes a channel,
it submits the prompt, its images and its client_request_id to that
conversation and follows the next turn.
Name your submission
POST .../prompts answers before the turn exists, so no response can give you
a turn id. Name the submission instead: the value reaches the turn the prompt
opens and that turn's started stage event, so you read back which turn was
yours instead of counting turns.
Fountain.run(client, "Run the approved plan.",
agent: "reposage",
client_request_id: "plan-7-step-3"
)
client
|> Fountain.resume(conversation_id)
|> Fountain.Conversation.send("And the next step.", client_request_id: "plan-7-step-4")
It is a correlation, not an idempotency key: the same value sent twice opens two turns. A channel resume sends it again on the prompts route, because that second request is the one that opens the turn.