Journey

Some slices are about building new things. This one was about fixing what broke and laying the foundation for what’s next.

On May 28, Clankies had a bug slam — four issues reported and fixed in a single afternoon session. The avatar editor wiped form fields, the Power-Up button did nothing when clicked, agents went silent when no API key was configured, and toast messages had a weird pinkish ghost background. Each bug had a clear root cause, a targeted one-file fix, and all four were reviewed, approved, and deployed within hours.

Then the Trello Power-Up integration — designed in ADR-004 and sketched out in the previous slice — got its full backend implementation. Seven modules across the Trello boundary context, 1,634 lines of production code, and 1,424 lines of tests covering OAuth token exchange, webhook lifecycle, card/board API operations, and the agent bridge. The architecture decisions are documented, the test suite is comprehensive, and the config UI (slice 03) now has a real backend to talk to.

A Brevo SMTP regression — tls: :always instead of tls: :if_available on port 587 — was caught and fixed before it could break production email delivery again.

Here’s what happened.

What Changed

1. Bug Slam: Four Bugs, One Afternoon

Four issues hit the kanban in quick succession on May 28. The flash agent fixed all of them in under an hour of wall-clock time, with the code-reviewer profile reviewing and unblocking each one.

Bug 1: Changing Clanky’s avatar wiped all other form fields

Symptom: Clicking an emoji in the avatar selector caused every input field (name, description, system prompt) to reset to empty values.

Root cause: The agent config form used for={%{}} — an empty map — instead of an Ecto changeset. Without a changeset-backed form, LiveView’s form recovery mechanism had nothing to recover from. When phx-click on the emoji triggered a server re-render, DOM patching replaced all <input> elements, and the user’s typed-in values vanished.

Fix: Four changes in agent_live.ex:

# Before: form had no changeset — no form recovery
<.form for={%{}} id="agent-config-form" phx-submit="update_agent">
# After: changeset-backed form with automatic form recovery
# mount assigns:
config_changeset: Agents.change_agent(agent)

# select_config_avatar updates the changeset:
socket = assign(socket, :config_changeset,
  Ecto.Changeset.put_change(socket.assigns.config_changeset, :emoji, emoji))

# form template:
<.form for={@config_changeset} id="agent-config-form" phx-submit="update_agent">

LiveView’s built-in form recovery works by comparing server-assigned changeset values with client-side <input> values. When the emoji changes, only that field gets patched; the rest stay intact.

Bug 2: “Add Power-Up” button did nothing when clicked

Symptom: Clicking the “+ Add Power-Up” button showed no visible response. The dropdown menu never appeared.

Root cause: The phx-click attribute toggled the open CSS class on #powerup-menu — the child .dropdown-content div. But the DaisyUI CSS selector .dropdown.open .dropdown-content expects open on the parent .dropdown container. The class was being added to the wrong element.

Fix: Added id="powerup-dropdown" to the parent container and changed the toggle target:

# Before: toggling open on the content div
<div class="dropdown mt-3">
  <button phx-click={JS.toggle_class("open", to: "#powerup-menu")}>
  <div id="powerup-menu" class="dropdown-content">

# After: toggling open on the parent container
<div class="dropdown mt-3" id="powerup-dropdown">
  <button phx-click={JS.toggle_class("open", to: "#powerup-dropdown")}>
  <div class="dropdown-content">

A one-character fix (well, two lines) for something that visibly blocks the entire Power-Up workflow.

Bug 3: Agents not responding in chat

Symptom: Users sent messages to their agent, saw a brief typing indicator flicker, then silence. The message appeared sent but no response arrived — as if the agent was ignoring the user.

Root cause: The user_api_keys table was empty. No one had configured a DeepSeek API key. The agent code correctly detected this (get_adapter_config returned api_key: nil), but the error was displayed only as a flash notification — which auto-dismisses after a few seconds. By the time the user looked at the chat area, the flash was gone and they saw only the empty chat.

Fix: Modified handle_info({:error, reason}, socket) in agent_live.ex to add the error as a persistent inline assistant message in the chat area. Now users clearly see “API key not configured — go to Settings to add one” instead of confusing silence.

Bug 4: Pinkish ghost background on toast alerts

Symptom: Toast/flash messages had a strange pinkish color that looked like text with a background highlight, rather than a proper alert background.

Login page with the styled flash notifications

Root cause: The flash component rendered an outer .toast-message div with background: var(--n) (dark neutral) wrapping an inner div with .alert-error class adding its own background: color-mix(in oklch, var(--er) 10%, var(--b1)) (light pink). Two overlapping backgrounds created a visual artifact — the pink appeared as a separate patch behind the text instead of filling the entire toast.

Fix: Removed background, color, and padding from the outer .toast-message and moved padding (p-3) to the inner div. The inner .alert-* div now provides the sole background.

2. Brevo SMTP STARTTLS Fix

A one-line change in config/runtime.exs that took three commits to get right.

The problem: Brevo’s SMTP relay on port 587 uses STARTTLS — the connection starts plaintext and upgrades to TLS via the STARTTLS command. Setting tls: :always in the Swoosh/SMPT config causes gen_smtp to expect a TLS handshake from the first byte, which Brevo doesn’t provide on port 587. The result: {:error, {:retries_exceeded, {:temporary_failure, "smtp-relay.brevo.com", :tls_failed}}}.

The fix: tls: :if_available — which lets gen_smtp start with plaintext, negotiate STARTTLS if the server advertises it, and upgrade the connection.

# Before (wrong for Brevo on port 587):
config :clankies, Clankies.Mailer,
  adapter: Swoosh.Adapters.SMTP,
  relay: smtp_relay,
  port: 587,
  tls: :always  # ← expects TLS from byte 1

# After (correct):
config :clankies, Clankies.Mailer,
  adapter: Swoosh.Adapters.SMTP,
  relay: smtp_relay,
  port: 587,
  tls: :if_available  # ← STARTTLS negotiation

The meta-bug: This was originally fixed in commit f09a540, but the flash agent’s Trello backend work touched runtime.exs and silently reverted the fix to tls: :always in commit 4196494. A git log -- config/runtime.exs before deploy would have caught it. The project now has a deploy checklist item: always check for reverted config fixes.

3. Trello Backend Architecture (ADR-004)

The Trello integration backend is built as three layered components, documented in ADR-004:

sequenceDiagram
    participant User as Browser User
    participant Trello as Trello API
    participant Auth as Auth Module
    participant Webhook as EventProcessor
    participant Agent as AgentServer

    Note over User,Agent: OAuth Flow
    User->>Trello: GET /1/authorize (redirect)
    Trello->>User: Token returned
    User->>Auth: handle_callback(token)
    Auth->>Trello: GET /1/members/me (validate)
    Trello-->>Auth: {id, username}
    Auth->>DB: upsert TrelloConnection
    Auth-->>User: ✅ Connected

    Note over User,Agent: Webhook Event Flow
    Trello->>Webhook: POST /api/trello/webhook
    Webhook->>Webhook: Dedup by action.id (ETS)
    Webhook->>Webhook: Filter self-triggered events
    Webhook->>Webhook: Format event → agent message
    Webhook->>Agent: send_message(board_action)
    Agent-->>Webhook: async processing
    Webhook-->>Trello: 200 OK

    Note over User,Agent: Agent Tool Flow
    Agent->>ApiClient: trello_get_card(card_id)
    ApiClient->>Trello: GET /1/cards/:id
    Trello-->>ApiClient: {id, name, desc, ...}
    ApiClient-->>Agent: formatted card data

Seven backend modules, one clear boundary:

Module Lines Responsibility
Clankies.Trello.Auth 133 OAuth token exchange, validation via Trello API
Clankies.Trello.ApiClient 212 Req-based Trello REST client, test plug injection
Clankies.Trello.EventProcessor 187 Webhook dedup (ETS), self-filtering, async dispatch
Clankies.Trello.WebhookRegistrar 63 Webhook lifecycle (register/deregister)
Clankies.Trello.TrelloConnection 47 Ecto schema: stored Trello OAuth tokens
Clankies.Trello.TrelloBoardMapping 34 Ecto schema: board-to-agent link
Clankies.Trello (context) 153 CRUD operations, webhook orchestration

Total: 1,634 lines of backend code + 1,424 lines of tests across 10 test files.

Key architectural decisions from ADR-004:

  • API Key + Token auth (not OAuth 2.0) — Trello’s native authorization model; token can be set to never expire
  • ETS-based webhook dedup — simple, fast, avoids DB round-trips for the common case; 5-minute TTL
  • Async webhook processingTask.start to return 200 OK immediately, then process asynchronously
  • Test-mode plug injectionApplication.get_env(:clankies, :trello_test_plug) lets tests stub Trello API responses via a local Plug instead of hitting the real API
  • Keyword-list AND map paramsApiClient.request/4 handles both param styles without crashing
# ApiClient.request/4 now handles both param styles
defp merge_params(params, api_key, trello_connection) do
  case params do
    list when is_list(list) ->
      Keyword.merge(list, key: api_key, token: trello_connection.access_token)
    map when is_map(map) ->
      Map.merge(map, %{key: api_key, token: trello_connection.access_token})
  end
end
# Webhook dedup via ETS — fast, simple, no DB query
defp deduplicate(action_id) do
  now = System.system_time(:second)
  case :ets.lookup(@ets_table, action_id) do
    [{^action_id, _timestamp}] ->
      Logger.debug("Dropping duplicate Trello webhook: #{action_id}")
      :duplicate
    [] ->
      :ets.insert(@ets_table, {action_id, now + @dedup_ttl_minutes * 60})
      :ok
  end
end

4. Trello Config UI Refinements (Uncommitted)

The Trello Settings LiveView gained several UX improvements that are in working-tree changes awaiting review:

  • Auto-load boards — when the page loads with an active Trello connection, boards are fetched automatically after 100ms
  • Board search — text input with client-side filtering by board name (case-insensitive substring match)
  • Map confirmation animation — a subtle pulse-once keyframe (two beats of 1.02x scale) draws the user’s eye to the newly-mapped board card
  • Error handler expansion — the map_board handler now catches {:error, reason} when reason is a string (not just Ecto changeset errors)
  • State cleanup — many event handlers now clear stale assigns (mapped_confirmation, search_query) to prevent visual bugs

Trello Integration settings page showing the configuration UI

# Auto-load boards on mount (if already connected)
if connection do
  Process.send_after(self(), :auto_load_boards, 100)
end

# Board search with case-insensitive filtering
defp filtered_boards(boards, query) do
  query_lower = String.downcase(query)
  Enum.filter(boards, fn board ->
    String.contains?(String.downcase(board.name), query_lower)
  end)
end
/* Pulse-once confirmation animation */
@keyframes pulse-once {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.02); }
}
.animate-pulse-once {
  animation: pulse-once 0.6s ease-in-out 2;
}

Architecture

The Trello integration follows the project’s existing pattern: a boundary context (lib/clankies/trello/) with its own modules, schemas, and context module, plus controller/LiveView layers in the web layer. The full data flow from user action to Trello API to agent response:

graph TD
    subgraph Browser
        U[User]
        LV[TrelloSettingsLive]
    end

    subgraph Web
        TC[TrelloController]
        TAC[TrelloApiController]
        TWC[TrelloWebhookController]
    end

    subgraph Domain
        A[Auth<br/>OAuth flow]
        AC[ApiClient<br/>Req-based Trello client]
        EP[EventProcessor<br/>ETS dedup + format]
        WR[WebhookRegistrar<br/>Lifecycle mgmt]
        TX[Trello context<br/>CRUD + orchestration]
    end

    subgraph Data
        TC_s[TrelloConnection]
        TBM[TrelloBoardMapping]
    end

    subgraph Agent
        AS[AgentServer<br/>message dispatch]
        PU[PowerUp module<br/>tools + prompts]
    end

    U -->|Settings| LV
    LV -->|Connect| TC
    TC -->|Authorize| A
    A -->|Validate token| AC
    AC -->|GET /1/members/me| TrelloAPI
    A -->|Upsert| TC_s
    LV -->|Load boards| TAC
    TAC -->|List boards| AC
    AC -->|GET /1/members/me/boards| TrelloAPI
    LV -->|Map board| TAC
    TAC -->|Create mapping| TX
    TX -->|Register webhook| WR
    WR -->|POST /1/webhooks| TrelloAPI
    WR -->|Store webhook_id| TBM
    TrelloAPI -->|Event| TWC
    TWC -->|Process| EP
    EP -->|Route to agent| AS
    AS -->|Execute tool| PU
    PU -->|API call| AC
    AC -->|GET/POST /1/*| TrelloAPI

    style TrelloAPI fill:#0079BF,color:#fff
    style PU fill:#6C5CE7,color:#fff

The integration is structured as a Trello context module (Clankies.Trello) that orchestrates the sub-modules. This keeps the web layer thin: controllers call the context, the context manages the data and external API interactions.

Debugging

Bug Symptom Root Cause Fix
Avatar form wipes fields Changing emoji resets all inputs for={%{}} instead of changeset disables form recovery Changeset-backed form with Ecto.Changeset.put_change
Power-Up button silent Clicking button shows no menu open class toggled on child .dropdown-content, not parent .dropdown Updated phx-click target to parent container
Agent not responding Message sent, typing indicator, then silence API key missing; error shown as flash (auto-dismisses) not inline message Error bubbles now persist as chat messages
Pinkish toast ghost Alert text looks highlighted pink Outer toast-message background: var(--n) overlaps inner .alert-error pink bg Removed outer bg; inner div provides full bg
Brevo email failure Emails not delivered tls: :always on port 587 expects TLS from byte 1; Brevo uses STARTTLS Changed to tls: :if_available
map_board crash LiveView error on board mapping Handler only matched {:error, %Ecto.Changeset{}}, not {:error, "string"} Added {:error, reason} clause

Technical Decisions

Decision Rationale Trade-offs
Changeset-backed form LiveView’s form recovery is built-in and zero-config Requires discipline to keep changeset in sync across events
ETS webhook dedup Fast, no DB query, survives only in-memory State lost on restart; 5-min TTL is arbitrary
Async webhook processing Returns 200 OK immediately; Trello retries on timeout Error handling is decoupled from the request lifecycle
Test plug injection Clean test stubbing without mock server App config coupling; prod path has conditional branch
tls: :if_available STARTTLS negotiation for Brevo on port 587 Less secure if connection downgrade is possible (mitigated by enforcing port 587)

What We Learned

  • One bad CSS selector can break an entire feature. The Power-Up button wasn’t just “not working” — it was working perfectly on the wrong DOM element. Always check which element carries the toggle class.
  • Flash notifications are silent failures. If an error auto-dismisses in 3 seconds and the user isn’t staring at the toast, they’ll think the app is broken. Persistent inline messages are the only reliable way to show errors.
  • Config regressions are hard to catch automatically. The flash agent works fast and doesn’t read git history. Always git log -- config/runtime.exs before deploy — the project now has this as a deploy checklist item.
  • ETS is perfect for webhook dedup. Trello sends at-least-once events. A named ETS table with a 5-minute TTL is simpler and faster than a DB query, and the trade-off (lost on restart) is acceptable for webhook dedup.

What’s Next

The Trello backend is complete and tested. The board selection UI has two child tasks ready for the flash agent: fix 8 failing LiveView tests (Slice 1a) and polish the board loading UX with refresh, error recovery, empty states, and skeleton loading (Slice 1b). After that, the final stretch: the list/label mapping UI and end-to-end integration testing.