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.
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
-
Phoenix 1.8.7 ships with DaisyUI v5 — many v4 classes are removed. Must use raw Tailwind for forms, inputs, and chat components.
-
DeepSeek V4 requires
reasoning_contentpass-through — when the LLM thinks before calling a tool, that reasoning must be included in subsequent API calls or the API rejects them. -
Req’sinto:callback uses{req, %Req.Response{}}as the accumulator tuple, not{req, list}. The response body starts as""and accumulates raw bytes. -
LiveView auth in Phoenix 1.8 requires both
live_sessionwithon_mounthooks and handling of bothSocketandConnin the hook. -
The grill-me skill was invaluable — 51 structured questions exposed gaps that would have been painful mid-implementation.
-
Debugging with
mix run -ewas 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.