← Back to task

Commit 00b3042e

commit 00b3042ed8ede6d5bf8f98c74a299bb118570750
Author: Coder Agent <coder@agents.omni>
Date:   Wed Apr 15 22:05:09 2026

    feat(agents): replace agentd HTTP calls with local runtime APIs
    
    Rework Omni.Agents.Client to call agentd runtime modules directly.
    
    Migrate Ava web and Telegram orchestrator agent control to Client.
    
    Task-Id: t-802

diff --git a/Omni/Agents/Client.hs b/Omni/Agents/Client.hs
index 409187c3..a30f303d 100644
--- a/Omni/Agents/Client.hs
+++ b/Omni/Agents/Client.hs
@@ -3,19 +3,15 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
--- | HTTP client for the Agentd daemon API.
+-- | Agent lifecycle client backed by the local agentd CLI/runtime modules.
 --
--- The web server calls these functions to manage agents via the daemon
--- running at localhost:8400 (configurable via AGENTD_URL env var).
+-- This module intentionally avoids the removed agentd HTTP daemon API.
 --
 -- : dep aeson
 -- : dep bytestring
--- : dep http-conduit
+-- : dep uuid
 module Omni.Agents.Client
-  ( -- * Configuration
-    daemonUrl,
-
-    -- * Types
+  ( -- * Types
     AgentInfo (..),
     AgentStatus (..),
     SpawnRequest (..),
@@ -38,18 +34,11 @@ import qualified Control.Monad.Fail as Fail
 import Data.Aeson ((.:), (.:?), (.=))
 import qualified Data.Aeson as Aeson
 import qualified Data.Text as Text
-import qualified Network.HTTP.Simple as HTTP
-import qualified System.Environment as Env
-
--- ============================================================================
--- Configuration
--- ============================================================================
-
--- | Get the daemon base URL from env or use default.
-daemonUrl :: IO Text
-daemonUrl = do
-  mUrl <- Env.lookupEnv "AGENTD_URL"
-  pure (maybe "http://localhost:8400" Text.pack mUrl)
+import qualified Data.Time as Time
+import qualified Data.UUID as UUID
+import qualified Data.UUID.V4 as UUID
+import qualified Omni.Agent.Models as Models
+import qualified Omni.Agentd.Daemon as Daemon
 
 -- ============================================================================
 -- Types
@@ -105,8 +94,8 @@ instance Aeson.FromJSON AgentInfo where
       AgentInfo
         </ (o .: "run_id")
         <*> (o .: "status")
-        <*> (o .: "prompt")
-        <*> (o .: "workspace")
+        <*> (o .:? "prompt" Aeson..!= "")
+        <*> (o .:? "workspace" Aeson..!= "")
         <*> (o .:? "pid")
         <*> (o .:? "started_at")
         <*> (o .:? "completed_at")
@@ -168,115 +157,158 @@ instance Aeson.FromJSON ChatMessage where
         <*> (o .: "content")
         <*> (o .: "created_at")
 
+-- ============================================================================
+-- Helpers
+-- ============================================================================
+
+daemonStatusToClient :: Daemon.AgentStatus -> AgentStatus
+daemonStatusToClient = \case
+  Daemon.StatusPending -> StatusPending
+  Daemon.StatusRunning -> StatusRunning
+  Daemon.StatusIdle -> StatusIdle
+  Daemon.StatusCompleted -> StatusCompleted
+  Daemon.StatusFailed -> StatusFailed
+  Daemon.StatusStopped -> StatusStopped
+
+formatTimestamp :: Time.UTCTime -> Text
+formatTimestamp = Text.pack <. Time.formatTime Time.defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC"
+
+persistentToAgentInfo :: Daemon.PersistentAgent -> AgentInfo
+persistentToAgentInfo pa =
+  let cfg = Daemon.paConfig pa
+      mInfo = Daemon.paDbInfo pa
+   in AgentInfo
+        { aiRunId = Daemon.acName cfg,
+          aiStatus = maybe StatusStopped (daemonStatusToClient <. Daemon.aiStatus) mInfo,
+          aiPrompt = fromMaybe "" (Daemon.aiPrompt </ mInfo),
+          aiWorkspace = fromMaybe (Text.pack (Daemon.acCwd cfg)) (Daemon.aiWorkspace </ mInfo),
+          aiPid = Daemon.aiPid =<< mInfo,
+          aiStartedAt = formatTimestamp </ (Daemon.aiStartedAt =<< mInfo),
+          aiCompletedAt = formatTimestamp </ (Daemon.aiCompletedAt =<< mInfo),
+          aiError = Daemon.aiError =<< mInfo,
+          aiSummary = Daemon.aiSummary =<< mInfo,
+          aiCostCents = Daemon.aiCostCents =<< mInfo,
+          aiTitle = Daemon.aiTitle =<< mInfo
+        }
+
+nonEmptyText :: Maybe Text -> Maybe Text
+nonEmptyText = \case
+  Nothing -> Nothing
+  Just txt ->
+    let stripped = Text.strip txt
+     in if Text.null stripped then Nothing else Just stripped
+
+generateAgentName :: IO Text
+generateAgentName = do
+  uuid <- UUID.nextRandom
+  pure ("agent-" <> UUID.toText uuid)
+
+sendPromptWithRetry :: Text -> Text -> Int -> IO (Either Text ())
+sendPromptWithRetry runId prompt retries = do
+  result <- Daemon.sendPersistentAgent Nothing runId prompt
+  case result of
+    Left err
+      | retries > 0,
+        "FIFO not found" `Text.isInfixOf` err -> do
+          threadDelay 200000
+          sendPromptWithRetry runId prompt (retries - 1)
+    Left err -> pure (Left err)
+    Right () -> pure (Right ())
+
 -- ============================================================================
 -- API Calls
 -- ============================================================================
 
--- | List all agents from the daemon.
+-- | List all persistent agents.
 listAgents :: IO (Either Text [AgentInfo])
 listAgents = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents"))
-      resp <- HTTP.httpJSON req
-      pure (HTTP.getResponseBody resp :: [AgentInfo])
+  result <- try @SomeException <| Daemon.listPersistentAgents Nothing
   pure <| case result of
     Left err -> Left ("Failed to list agents: " <> tshow err)
-    Right agents -> Right agents
+    Right agents -> Right (map persistentToAgentInfo agents)
 
--- | Get a single agent's info.
+-- | Get a single persistent agent's info.
 getAgent :: Text -> IO (Either Text AgentInfo)
 getAgent runId = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents/" <> runId))
-      resp <- HTTP.httpJSON req
-      pure (HTTP.getResponseBody resp :: AgentInfo)
+  result <- try @SomeException <| Daemon.getPersistentAgent Nothing runId
   pure <| case result of
     Left err -> Left ("Failed to get agent: " <> tshow err)
-    Right agent -> Right agent
+    Right Nothing -> Left ("Agent not found: " <> runId)
+    Right (Just agent) -> Right (persistentToAgentInfo agent)
 
--- | Get conversation messages for an agent.
+-- | Get conversation messages for a persistent agent.
 getMessages :: Text -> IO (Either Text [ChatMessage])
 getMessages runId = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents/" <> runId <> "/messages"))
-      resp <- HTTP.httpJSON req
-      pure (HTTP.getResponseBody resp :: [ChatMessage])
+  result <- try @SomeException <| Daemon.getPersistentMessages Nothing runId
   pure <| case result of
     Left err -> Left ("Failed to get messages: " <> tshow err)
-    Right msgs -> Right msgs
+    Right values ->
+      let messages = mapMaybe decodeMessage values
+       in Right messages
+  where
+    decodeMessage value =
+      case Aeson.fromJSON value of
+        Aeson.Success msg -> Just msg
+        Aeson.Error _ -> Nothing
 
--- | Spawn a new agent.
+-- | Create/start a persistent agent and optionally send initial prompt.
 spawnAgent :: SpawnRequest -> IO (Either Text SpawnResponse)
 spawnAgent spawnReq = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents"))
-      let req' =
-            HTTP.setRequestBodyJSON spawnReq
-              <| HTTP.setRequestMethod "POST" req
-      resp <- HTTP.httpJSON req'
-      pure (HTTP.getResponseBody resp :: SpawnResponse)
-  pure <| case result of
-    Left err -> Left ("Failed to spawn agent: " <> tshow err)
-    Right resp -> Right resp
+  runId <- maybe generateAgentName pure (nonEmptyText (srName spawnReq))
+  let cfg =
+        Daemon.AgentConfig
+          { Daemon.acName = runId,
+            Daemon.acProvider = fromMaybe "auto" (nonEmptyText (srProvider spawnReq)),
+            Daemon.acModel = fromMaybe Models.defaultModel (nonEmptyText (srModel spawnReq)),
+            Daemon.acCwd = Text.unpack (srCwd spawnReq),
+            Daemon.acThinking = fromMaybe "high" (nonEmptyText (srThinking spawnReq)),
+            Daemon.acExtraArgs = nonEmptyText (srExtraArgs spawnReq),
+            Daemon.acExtraEnv = mempty
+          }
+
+  createResult <- Daemon.createAgent Nothing cfg
+  case createResult of
+    Left err
+      | "Agent already exists:" `Text.isPrefixOf` err -> startAndPrime runId
+    Left err -> pure (Left ("Failed to spawn agent: " <> err))
+    Right _ -> startAndPrime runId
+  where
+    startAndPrime runId = do
+      startResult <- Daemon.startPersistentAgent Nothing runId
+      case startResult of
+        Left err -> pure (Left ("Failed to start agent: " <> err))
+        Right _ -> do
+          sendResult <-
+            case nonEmptyText (srPrompt spawnReq) of
+              Nothing -> pure (Right ())
+              Just prompt -> sendPromptWithRetry runId prompt 10
+          case sendResult of
+            Left err -> pure (Left ("Failed to send initial prompt: " <> err))
+            Right () -> pure (Right (SpawnResponse runId "started"))
 
--- | Send a message to a running/idle agent.
+-- | Send a message to a running/idle persistent agent.
 sendMessage :: Text -> Text -> IO (Either Text ())
 sendMessage runId message = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents/" <> runId <> "/send"))
-      let body = Aeson.object ["message" .= message]
-          req' =
-            HTTP.setRequestBodyJSON body
-              <| HTTP.setRequestMethod "POST" req
-      resp <- HTTP.httpLBS req'
-      let code = HTTP.getResponseStatusCode resp
-      if code >= 200 && code < 300
-        then pure ()
-        else panic ("HTTP " <> tshow code)
+  result <- try @SomeException <| Daemon.sendPersistentAgent Nothing runId message
   pure <| case result of
     Left err -> Left ("Failed to send: " <> tshow err)
-    Right () -> Right ()
+    Right (Left err) -> Left err
+    Right (Right ()) -> Right ()
 
--- | Stop a running agent.
+-- | Stop a persistent agent.
 stopAgent :: Text -> IO (Either Text ())
 stopAgent runId = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents/" <> runId <> "/stop"))
-      let req' = HTTP.setRequestMethod "POST" req
-      resp <- HTTP.httpLBS req'
-      let code = HTTP.getResponseStatusCode resp
-      if code >= 200 && code < 300
-        then pure ()
-        else panic ("HTTP " <> tshow code)
+  result <- try @SomeException <| Daemon.stopPersistentAgent Nothing runId
   pure <| case result of
     Left err -> Left ("Failed to stop: " <> tshow err)
-    Right () -> Right ()
+    Right (Left err) -> Left err
+    Right (Right _) -> Right ()
 
--- | Remove an agent and its config/state.
+-- | Remove a persistent agent and archive its DB history.
 removeAgent :: Text -> IO (Either Text ())
 removeAgent runId = do
-  url <- daemonUrl
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (url <> "/agents/" <> runId))
-      let req' = HTTP.setRequestMethod "DELETE" req
-      resp <- HTTP.httpLBS req'
-      let code = HTTP.getResponseStatusCode resp
-      if code >= 200 && code < 300
-        then pure ()
-        else panic ("HTTP " <> tshow code)
+  result <- try @SomeException <| Daemon.removePersistentAgent Nothing runId
   pure <| case result of
     Left err -> Left ("Failed to remove agent: " <> tshow err)
-    Right () -> Right ()
+    Right (Left err) -> Left err
+    Right (Right ()) -> Right ()
diff --git a/Omni/Ava/Telegram/Developer.hs b/Omni/Ava/Telegram/Developer.hs
index ffdd279b..82f5bc03 100644
--- a/Omni/Ava/Telegram/Developer.hs
+++ b/Omni/Ava/Telegram/Developer.hs
@@ -51,17 +51,19 @@ import qualified Control.Concurrent.STM as STM
 import qualified Control.Exception as Exception
 import Data.Aeson ((.:), (.=))
 import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
 import qualified Data.IORef as IORef
 import qualified Data.Map.Strict as Map
 import qualified Data.Text as Text
 import qualified Data.Text.IO as TextIO
 import qualified Data.Time as Time
 import qualified Network.HTTP.Simple as HTTP
+import qualified Omni.Agents.Client as Client
 import qualified Omni.Ava.Telegram.Types as Types
 import qualified Omni.Task.Core as Task
 import qualified Omni.Test as Test
 import qualified System.Directory as Dir
-import qualified System.Environment as Env
 import System.IO (hFlush)
 import System.IO.Unsafe (unsafePerformIO)
 
@@ -205,17 +207,6 @@ formatPhase tid _maxIter = \case
   PhaseFailed err ->
     "❌ " <> tid <> " failed: " <> Text.take 200 err
 
--- | Agentd spawn response
-newtype AgentdSpawnResponse = AgentdSpawnResponse
-  { asrRunId :: Text
-  }
-  deriving (Show, Eq, Generic)
-
-instance Aeson.FromJSON AgentdSpawnResponse where
-  parseJSON =
-    Aeson.withObject "AgentdSpawnResponse" <| \v ->
-      AgentdSpawnResponse </ (v Aeson..: "run_id")
-
 -- | Agent status info from agentd
 data AgentdInfo = AgentdInfo
   { adiRunId :: Text,
@@ -236,66 +227,76 @@ instance Aeson.FromJSON AgentdInfo where
         <*> (v Aeson..:? "error")
         <*> (v Aeson..:? "cost_cents")
 
--- | Base URL for agentd HTTP API
+-- | Legacy base-url hook retained for call-site compatibility.
 agentdBaseUrl :: IO Text
-agentdBaseUrl = do
-  mUrl <- Env.lookupEnv "AGENTD_URL"
-  pure <| maybe "http://127.0.0.1:8400" Text.pack mUrl
+agentdBaseUrl = pure "cli://agentd"
 
--- | Webhook URL for agentd -> Ava notifications
+-- | Webhook URL is unused in CLI mode.
 agentdWebhookUrl :: IO (Maybe Text)
-agentdWebhookUrl = do
-  mUrl <- Env.lookupEnv "AGENTD_WEBHOOK_URL"
-  pure <| Just (maybe "http://127.0.0.1:8079/agents/webhook" Text.pack mUrl)
+agentdWebhookUrl = pure Nothing
+
+lookupPayloadText :: Text -> Aeson.Object -> Maybe Text
+lookupPayloadText key obj =
+  case KeyMap.lookup (Key.fromText key) obj of
+    Just (Aeson.String txt) -> Just txt
+    _ -> Nothing
+
+nonEmptyPayloadText :: Maybe Text -> Maybe Text
+nonEmptyPayloadText = \case
+  Nothing -> Nothing
+  Just txt ->
+    let stripped = Text.strip txt
+     in if Text.null stripped then Nothing else Just stripped
 
 agentdSpawnAgent :: Text -> Aeson.Value -> IO (Either Text Text)
-agentdSpawnAgent baseUrl payload = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (baseUrl <> "/agents"))
-      let req' =
-            HTTP.setRequestBodyJSON payload
-              <| HTTP.setRequestMethod "POST" req
-      resp <- HTTP.httpJSON req' :: IO (HTTP.Response AgentdSpawnResponse)
-      pure <| asrRunId (HTTP.getResponseBody resp)
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right runId -> pure (Right runId)
+agentdSpawnAgent _ payload =
+  case payload of
+    Aeson.Object obj -> do
+      let spawnReq =
+            Client.SpawnRequest
+              { Client.srName = nonEmptyPayloadText (lookupPayloadText "name" obj),
+                Client.srProvider = nonEmptyPayloadText (lookupPayloadText "provider" obj),
+                Client.srModel = nonEmptyPayloadText (lookupPayloadText "model" obj),
+                Client.srCwd = fromMaybe "/home/ben/omni/ava" (lookupPayloadText "cwd" obj),
+                Client.srThinking = nonEmptyPayloadText (lookupPayloadText "thinking" obj),
+                Client.srExtraArgs = nonEmptyPayloadText (lookupPayloadText "extra_args" obj),
+                Client.srPrompt = nonEmptyPayloadText (lookupPayloadText "prompt" obj)
+              }
+      result <- Client.spawnAgent spawnReq
+      pure <| case result of
+        Left err -> Left err
+        Right resp -> Right (Client.spRunId resp)
+    _ -> pure (Left "Invalid spawn payload")
+
+agentdStatusText :: Client.AgentStatus -> Text
+agentdStatusText = \case
+  Client.StatusPending -> "pending"
+  Client.StatusRunning -> "running"
+  Client.StatusIdle -> "idle"
+  Client.StatusCompleted -> "completed"
+  Client.StatusFailed -> "failed"
+  Client.StatusStopped -> "stopped"
 
 agentdGetInfo :: Text -> Text -> IO (Either Text AgentdInfo)
-agentdGetInfo baseUrl runId = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (baseUrl <> "/agents/" <> runId))
-      resp <- HTTP.httpJSON req :: IO (HTTP.Response AgentdInfo)
-      pure <| HTTP.getResponseBody resp
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right info -> pure (Right info)
+agentdGetInfo _ runId = do
+  result <- Client.getAgent runId
+  pure <| case result of
+    Left err -> Left err
+    Right info ->
+      Right
+        AgentdInfo
+          { adiRunId = Client.aiRunId info,
+            adiStatus = agentdStatusText (Client.aiStatus info),
+            adiSummary = Client.aiSummary info,
+            adiError = Client.aiError info,
+            adiCostCents = Client.aiCostCents info
+          }
 
 agentdStop :: Text -> Text -> IO (Either Text ())
-agentdStop baseUrl runId = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (baseUrl <> "/agents/" <> runId <> "/stop"))
-      let req' = HTTP.setRequestMethod "POST" req
-      _ <- HTTP.httpNoBody req'
-      pure ()
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right () -> pure (Right ())
+agentdStop _ = Client.stopAgent
 
 agentdDelete :: Text -> Text -> IO (Either Text ())
-agentdDelete baseUrl runId = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (baseUrl <> "/agents/" <> runId))
-      let req' = HTTP.setRequestMethod "DELETE" req
-      _ <- HTTP.httpNoBody req'
-      pure ()
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right () -> pure (Right ())
+agentdDelete _ = Client.removeAgent
 
 cleanupAgentdRun :: Text -> Text -> IO ()
 cleanupAgentdRun baseUrl runId = do
diff --git a/Omni/Ava/Web.hs b/Omni/Ava/Web.hs
index c056ab9b..d651bb71 100644
--- a/Omni/Ava/Web.hs
+++ b/Omni/Ava/Web.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE DataKinds #-}
 {-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeOperators #-}
 {-# LANGUAGE NoImplicitPrelude #-}
@@ -43,16 +44,15 @@ import qualified Data.Text as Text
 import qualified Data.Text.Encoding as TE
 import qualified Database.SQLite.Simple as SQL
 import qualified Lucid
-import qualified Network.HTTP.Simple as HTTP
 import qualified Network.Wai.Handler.Warp as Warp
 import qualified Omni.Agent.Trace.Storage as Trace
+import qualified Omni.Agents.Client as Client
 import qualified Omni.Ava.Agent.Backend as Agent
 import qualified Omni.Task.Web.Handlers as TaskWeb
 import Omni.Task.Web.Pages ()
 import Omni.Task.Web.Partials ()
 import Servant
 import qualified Servant.HTML.Lucid as Lucid
-import qualified System.Environment as Env
 
 main :: IO ()
 main = putText "Use Omni.Ava for the main entry point"
@@ -186,66 +186,82 @@ instance Aeson.FromJSON AgentdSpawnResponse where
 defaultAgentPrompt :: Text
 defaultAgentPrompt = "Initialize and wait for tasks."
 
--- | Agentd base URL (override with AGENTD_URL)
+-- | Legacy base-url hook retained for call-site compatibility.
 agentdBaseUrl :: IO Text
-agentdBaseUrl = do
-  mUrl <- Env.lookupEnv "AGENTD_URL"
-  pure <| maybe "http://127.0.0.1:8400" Text.pack mUrl
+agentdBaseUrl = pure "cli://agentd"
 
 agentdWebhookUrl :: Text
-agentdWebhookUrl = "http://127.0.0.1:8079/agents/webhook"
+agentdWebhookUrl = "cli://agentd-webhook-disabled"
+
+lookupPayloadText :: Text -> Aeson.Object -> Maybe Text
+lookupPayloadText key obj =
+  case KeyMap.lookup (Key.fromText key) obj of
+    Just (Aeson.String txt) -> Just txt
+    _ -> Nothing
+
+nonEmptyPayloadText :: Maybe Text -> Maybe Text
+nonEmptyPayloadText = \case
+  Nothing -> Nothing
+  Just txt ->
+    let stripped = Text.strip txt
+     in if Text.null stripped then Nothing else Just stripped
+
+parseAgentPathWithTail :: Text -> Text -> Maybe Text
+parseAgentPathWithTail tailSegment path =
+  case Text.splitOn "/" (Text.strip path) of
+    ["", "agents", rid, tailValue]
+      | tailValue == tailSegment,
+        not (Text.null rid) ->
+          Just rid
+    _ -> Nothing
+
+parseAgentPath :: Text -> Maybe Text
+parseAgentPath path =
+  case Text.splitOn "/" (Text.strip path) of
+    ["", "agents", rid]
+      | not (Text.null rid) -> Just rid
+    _ -> Nothing
 
 agentdSpawnAgent :: Text -> Aeson.Value -> IO (Either Text AgentdSpawnResponse)
-agentdSpawnAgent base payload = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (base <> "/agents"))
-      let req' =
-            HTTP.setRequestBodyJSON payload
-              <| HTTP.setRequestMethod "POST" req
-      resp <- HTTP.httpJSON req'
-      pure (HTTP.getResponseBody resp :: AgentdSpawnResponse)
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right resp -> pure (Right resp)
+agentdSpawnAgent _ payload =
+  case payload of
+    Aeson.Object obj -> do
+      let spawnReq =
+            Client.SpawnRequest
+              { Client.srName = nonEmptyPayloadText (lookupPayloadText "name" obj),
+                Client.srProvider = nonEmptyPayloadText (lookupPayloadText "provider" obj),
+                Client.srModel = nonEmptyPayloadText (lookupPayloadText "model" obj),
+                Client.srCwd = fromMaybe "/home/ben/omni/live" (lookupPayloadText "cwd" obj),
+                Client.srThinking = nonEmptyPayloadText (lookupPayloadText "thinking" obj),
+                Client.srExtraArgs = nonEmptyPayloadText (lookupPayloadText "extra_args" obj),
+                Client.srPrompt = nonEmptyPayloadText (lookupPayloadText "prompt" obj)
+              }
+      result <- Client.spawnAgent spawnReq
+      pure <| case result of
+        Left err -> Left err
+        Right resp -> Right (AgentdSpawnResponse (Client.spRunId resp))
+    _ -> pure (Left "Invalid spawn payload")
 
 agentdPostJson :: Text -> Text -> Aeson.Value -> IO (Either Text ())
-agentdPostJson base path payload = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (base <> path))
-      let req' =
-            HTTP.setRequestBodyJSON payload
-              <| HTTP.setRequestMethod "POST" req
-      _ <- HTTP.httpNoBody req'
-      pure ()
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right () -> pure (Right ())
+agentdPostJson _ path payload =
+  case (parseAgentPathWithTail "send" path, payload) of
+    (Just runId, Aeson.Object obj) ->
+      case nonEmptyPayloadText (lookupPayloadText "message" obj) of
+        Nothing -> pure (Left "Missing message")
+        Just msg -> Client.sendMessage runId msg
+    _ -> pure (Left ("Unsupported agentd CLI route: " <> path))
 
 agentdPostEmpty :: Text -> Text -> IO (Either Text ())
-agentdPostEmpty base path = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (base <> path))
-      let req' = HTTP.setRequestMethod "POST" req
-      _ <- HTTP.httpNoBody req'
-      pure ()
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right () -> pure (Right ())
+agentdPostEmpty _ path =
+  case parseAgentPathWithTail "stop" path of
+    Just runId -> Client.stopAgent runId
+    Nothing -> pure (Left ("Unsupported agentd CLI route: " <> path))
 
 agentdDelete :: Text -> Text -> IO (Either Text ())
-agentdDelete base path = do
-  result <-
-    try @SomeException <| do
-      req <- HTTP.parseRequest (Text.unpack (base <> path))
-      let req' = HTTP.setRequestMethod "DELETE" req
-      _ <- HTTP.httpNoBody req'
-      pure ()
-  case result of
-    Left err -> pure (Left (tshow err))
-    Right () -> pure (Right ())
+agentdDelete _ path =
+  case parseAgentPath path of
+    Just runId -> Client.removeAgent runId
+    Nothing -> pure (Left ("Unsupported agentd CLI route: " <> path))
 
 -- | Webhook payload from agents
 data AgentWebhookPayload = AgentWebhookPayload