The Journey

Slice 01 was the first vertical slice of Clankies — an end-to-end working system where a user can create an AI agent, give it a Calculator power-up, and chat with it through DeepSeek. Everything from the Phoenix scaffold, through magic-link auth, to a streaming agent loop with tool calling.

We spent 51 questions grilling the design before writing a single line of code. Every architectural decision was debated: the agent lifecycle, the power-up contract, the adapter pattern, the GenServer supervision tree, the PubSub event system, the dashboard UX, the design system tokens. The result was a blueprint we could follow with confidence.

Here’s what we built.

Dashboard with Calculator Bot agent card

Agent chat interface with Calculator power-up

Architecture

The Agent GenServer Loop

At the heart of Clankies is an Elixir GenServer that drives the agent’s thinking. It follows the standard LLM agent pattern: think → tool call → observe → repeat.

flowchart TD
    A["💬 User message arrives"] --> B["Agent builds system prompt + messages + tool schemas"]
    B --> C["Calls DeepSeek adapter (chat_stream)"]
    C --> D["Streams back chunks"]
    D --> E{"Chunk type?"}
    E -->|"text"| F["Broadcast to UI via PubSub"]
    E -->|"reasoning_content"| G["Accumulate reasoning"]
    E -->|"tool_calls"| H["Extract tool call"]
    G --> D
    H --> I["Execute tool via power-up module"]
    I --> J["Feed result back with tool_call_id"]
    J --> B
    F --> K["✅ Wait for next message"]
    K -->|"after 5 min idle"| L["🛑 GenServer terminates"]

    style A fill:#e8f5e9,stroke:#4caf50
    style K fill:#e8f5e9,stroke:#4caf50
    style L fill:#ffebee,stroke:#ef5350
    style H fill:#e3f2fd,stroke:#42a5f5

The GenServer is started on-demand via DynamicSupervisor with :temporary restart strategy. It dies after 5 minutes of idle.

Full tool calling works end-to-end

A real conversation with the agent:

sequenceDiagram
    actor User
    participant Agent as 🤖 Agent GenServer
    participant LLM as 🧠 DeepSeek V4
    participant Tool as 🔧 Calculator

    User->>Agent: "Calculate 15 * 7"
    Agent->>LLM: chat_stream(messages, tools)
    LLM-->>Agent: reasoning_content + tool_call
    Agent->>Tool: execute("calculate", {expression: "15 * 7"})
    Tool-->>Agent: {:ok, %{result: 105}}
    Agent->>LLM: chat_stream(messages + tool_result, tools)
    LLM-->>Agent: "O resultado é **105**."
    Agent-->>User: Stream response

The DeepSeek V4 model emits reasoning_content (the thinking), calls the Calculator tool, and produces a final response after receiving the tool result.

Power-Up Behaviour

Power-ups are the system’s extensibility point. Each implements a behaviour with seven callbacks:

@callback tools(agent_config) :: [tool()]
@callback execute(tool_name, args, agent_config) :: {:ok, result} | {:error, reason}
@callback system_prompt_fragment(agent_config) :: String.t()
@callback config_schema() :: module()
@callback display_name() :: String.t()
@callback description() :: String.t()
@callback icon() :: String.t()

The Calculator uses Code.eval_string/2 with a safe math environment. It’s a single tool with zero external dependencies.

LLM Adapter Pattern

The adapter abstracts providers behind a streaming contract:

@callback chat_stream(messages, tools, config) :: {:ok, Enumerable.t()} | {:error, String.t()}
@callback list_models() :: [%{id: String.t(), name: String.t()}]

The DeepSeek adapter handles SSE streaming, retries with exponential backoff, and properly passes reasoning_content between tool calls — a DeepSeek V4 requirement.

Debugging: 9 bugs squashed

The integration phase uncovered multiple issues:

Bug Root cause Fix
start_link crash start_link/2 doesn’t match child spec Changed to start_link({id, uid})
Adapter nil Old model names not in registry Added legacy entries + nil-check
base_url nil Map.get/3 returns nil when key exists Map.get/3 || default
SSE not parsed data: prefix not stripped Strip prefix before JSON decode
Accumulator crash into: uses {req, %Req.Response{}} Store parsed chunks in resp.body
Tool loop hangs reasoning_content lost on loop Capture and pass back to LLM
Tool ID mismatch Atom key on string-keyed map Use tool_call["id"]
Form layout broken DaisyUI v5 removed v4 classes Replaced with Tailwind equivalents
current_scope nil LiveViews need on_mount + live_session Added auth hook for both Socket/Conn

These learnings are documented in AGENTS.md to prevent recurrence.

Technical Decisions

Decision Choice Rationale
Auth Magic links (phx.gen.auth) No password storage, Phoenix default
DB primary keys bigint (default) Matches phx.gen.auth convention
CSS framework DaisyUI v5 + Tailwind 4 Bundled with Phoenix 1.8.7
Power-up registry Compile-time list Simple, DB catalog later
API keys user_api_keys table Per-user, per-provider
Agent restart On-demand, DynamicSupervisor No boot-time overhead
Tool error handling Feed error back to LLM Agent self-corrects

What We Learned

  1. Phoenix 1.8.7 ships with DaisyUI v5 — many v4 classes are removed. Must use raw Tailwind for forms, inputs, and chat components.

  2. DeepSeek V4 requires reasoning_content pass-through — when the LLM thinks before calling a tool, that reasoning must be included in subsequent API calls or the API rejects them.

  3. Req’s into: callback uses {req, %Req.Response{}} as the accumulator tuple, not {req, list}. The response body starts as "" and accumulates raw bytes.

  4. LiveView auth in Phoenix 1.8 requires both live_session with on_mount hooks and handling of both Socket and Conn in the hook.

  5. The grill-me skill was invaluable — 51 structured questions exposed gaps that would have been painful mid-implementation.

  6. Debugging with mix run -e was essential for testing the agent loop without the full web interface.

What’s Next — Slice 04

Adding a Gmail power-up with OAuth integration — the first real external service pattern for the platform. This will validate the power-up contract established in Slice 01 against a production API.

See the full roadmap in BACKLOG.md.