This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit f4e75057d389985f1730274683b7c7557521bfaa
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 17:06:25 2026 +0800

    feat(agents): drive an agent from Gateway and watch it answer
---
 agent/fingerprint.go                |   5 +
 agent/fingerprints/claude-code.json |  24 ++
 agent/fingerprints/codebuddy.json   |  26 ++-
 agent/fingerprints/codex-cli.json   |  24 +-
 agent/fingerprints/gemini-cli.json  |  18 +-
 agent/fingerprints/qwen-code.json   |  26 ++-
 agent/headless.go                   |  90 ++++++++
 agentsession/command_unix.go        |  45 ++++
 agentsession/command_windows.go     |  97 ++++++++
 agentsession/event.go               |  80 +++++++
 agentsession/parse.go               |  66 ++++++
 agentsession/parse_claude.go        | 175 ++++++++++++++
 agentsession/parse_codex.go         | 165 ++++++++++++++
 agentsession/parse_text.go          |  38 ++++
 agentsession/runner.go              | 202 +++++++++++++++++
 agentsession/session.go             | 100 ++++++++
 agentsession/store.go               | 361 +++++++++++++++++++++++++++++
 controllers/agent_session.go        | 297 ++++++++++++++++++++++++
 main.go                             |   4 +
 object/agent_session.go             | 149 ++++++++++++
 object/ormer.go                     |   7 +
 routers/router.go                   |   9 +
 web/src/App.tsx                     |   2 +
 web/src/backend/DriveBackend.ts     |  69 ++++++
 web/src/locales/en/data.json        |  15 ++
 web/src/locales/zh/data.json        |  15 ++
 web/src/nav.ts                      |   4 +
 web/src/pages/ChatPage.tsx          | 441 ++++++++++++++++++++++++++++++++++++
 web/src/types.ts                    |  46 ++++
 29 files changed, 2596 insertions(+), 4 deletions(-)

diff --git a/agent/fingerprint.go b/agent/fingerprint.go
index 7fcded4..12cfe12 100644
--- a/agent/fingerprint.go
+++ b/agent/fingerprint.go
@@ -103,6 +103,11 @@ type Fingerprint struct {
        // both installs and upgrades. It runs through the platform shell, so 
it is
        // shown in full before anyone clicks it.
        InstallScript *InstallScript `json:"installScript,omitempty"`
+
+       // Headless is the agent's own non-interactive mode, which is what 
Gateway
+       // drives it through. Nil for an agent that publishes none: it can 
still be
+       // watched and configured, just not driven.
+       Headless *Headless `json:"headless,omitempty"`
 }
 
 // InstallScript is the vendor's installer command per platform. An empty
diff --git a/agent/fingerprints/claude-code.json 
b/agent/fingerprints/claude-code.json
index 34cd951..5e5232a 100644
--- a/agent/fingerprints/claude-code.json
+++ b/agent/fingerprints/claude-code.json
@@ -23,5 +23,29 @@
     "windows": "irm https://claude.ai/install.ps1 | iex",
     "darwin": "curl -fsSL https://claude.ai/install.sh | bash",
     "linux": "curl -fsSL https://claude.ai/install.sh | bash"
+  },
+  "headless": {
+    "args": [
+      "-p",
+      "--output-format",
+      "stream-json",
+      "--verbose",
+      "--session-id",
+      "{session}"
+    ],
+    "resumeArgs": [
+      "-p",
+      "--output-format",
+      "stream-json",
+      "--verbose",
+      "--resume",
+      "{session}"
+    ],
+    "modelArgs": [
+      "--model",
+      "{model}"
+    ],
+    "format": "claude-stream-json",
+    "promptStdin": true
   }
 }
diff --git a/agent/fingerprints/codebuddy.json 
b/agent/fingerprints/codebuddy.json
index 48949f9..aecf7f5 100644
--- a/agent/fingerprints/codebuddy.json
+++ b/agent/fingerprints/codebuddy.json
@@ -5,5 +5,29 @@
   "execName": "codebuddy",
   "instanceEnv": "CODEBUDDY_CONFIG_DIR",
   "stateDir": "codebuddy",
-  "npmPackage": "@tencent-ai/codebuddy-code"
+  "npmPackage": "@tencent-ai/codebuddy-code",
+  "headless": {
+    "args": [
+      "-p",
+      "--output-format",
+      "stream-json",
+      "--verbose",
+      "--session-id",
+      "{session}"
+    ],
+    "resumeArgs": [
+      "-p",
+      "--output-format",
+      "stream-json",
+      "--verbose",
+      "--resume",
+      "{session}"
+    ],
+    "modelArgs": [
+      "--model",
+      "{model}"
+    ],
+    "format": "claude-stream-json",
+    "promptStdin": true
+  }
 }
diff --git a/agent/fingerprints/codex-cli.json 
b/agent/fingerprints/codex-cli.json
index 8f3a755..58302f9 100644
--- a/agent/fingerprints/codex-cli.json
+++ b/agent/fingerprints/codex-cli.json
@@ -16,5 +16,27 @@
   ],
   "updateArgs": [
     "update"
-  ]
+  ],
+  "headless": {
+    "args": [
+      "exec",
+      "--json",
+      "--skip-git-repo-check",
+      "-"
+    ],
+    "resumeArgs": [
+      "exec",
+      "resume",
+      "--json",
+      "--skip-git-repo-check",
+      "{session}",
+      "-"
+    ],
+    "modelArgs": [
+      "--model",
+      "{model}"
+    ],
+    "format": "codex-json",
+    "promptStdin": true
+  }
 }
diff --git a/agent/fingerprints/gemini-cli.json 
b/agent/fingerprints/gemini-cli.json
index 1d3820e..be4251c 100644
--- a/agent/fingerprints/gemini-cli.json
+++ b/agent/fingerprints/gemini-cli.json
@@ -4,5 +4,21 @@
   "installUrl": "https://google-gemini.github.io/gemini-cli/";,
   "execName": "gemini",
   "stateDir": "gemini",
-  "npmPackage": "@google/gemini-cli"
+  "npmPackage": "@google/gemini-cli",
+  "headless": {
+    "args": [
+      "-p",
+      "",
+      "-o",
+      "text",
+      "--session-id",
+      "{session}"
+    ],
+    "modelArgs": [
+      "-m",
+      "{model}"
+    ],
+    "format": "text",
+    "promptStdin": true
+  }
 }
diff --git a/agent/fingerprints/qwen-code.json 
b/agent/fingerprints/qwen-code.json
index 9e4b838..43b94f4 100644
--- a/agent/fingerprints/qwen-code.json
+++ b/agent/fingerprints/qwen-code.json
@@ -4,5 +4,29 @@
   "installUrl": "https://github.com/QwenLM/qwen-code";,
   "execName": "qwen",
   "stateDir": "qwen",
-  "npmPackage": "@qwen-code/qwen-code"
+  "npmPackage": "@qwen-code/qwen-code",
+  "headless": {
+    "args": [
+      "-p",
+      "",
+      "-o",
+      "text",
+      "--session-id",
+      "{session}"
+    ],
+    "resumeArgs": [
+      "-p",
+      "",
+      "-o",
+      "text",
+      "--resume",
+      "{session}"
+    ],
+    "modelArgs": [
+      "-m",
+      "{model}"
+    ],
+    "format": "text",
+    "promptStdin": true
+  }
 }
diff --git a/agent/headless.go b/agent/headless.go
new file mode 100644
index 0000000..2fee7a0
--- /dev/null
+++ b/agent/headless.go
@@ -0,0 +1,90 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agent
+
+// Placeholders one of Headless's command lines carries. Each is replaced 
whole:
+// a placeholder is always an argument of its own, never part of one, so 
nothing
+// here has to be quoted or escaped.
+const (
+       PromptPlaceholder  = "{prompt}"
+       SessionPlaceholder = "{session}"
+       ModelPlaceholder   = "{model}"
+)
+
+// Headless is how one prompt is handed to an agent without a terminal, which 
is
+// what lets Gateway drive it on somebody's behalf.
+//
+// Args and ResumeArgs are whole command lines rather than a base and an
+// addition: the agents disagree about where a resumed prompt goes - Codex 
takes
+// a subcommand, the Claude Code family a flag - and writing both out is what
+// keeps that disagreement out of the code.
+type Headless struct {
+       // Args starts a new conversation. An agent that carries 
SessionPlaceholder
+       // here is told which session it is starting, so Gateway names it 
rather than
+       // reading the name back out of the output.
+       Args []string `json:"args"`
+       // ResumeArgs carries on the conversation Gateway last started. Empty 
for an
+       // agent that cannot resume one, whose every turn then stands alone.
+       ResumeArgs []string `json:"resumeArgs,omitempty"`
+       // ModelArgs are appended when a model was picked for the session, and 
left
+       // out entirely when none was, so the agent keeps its own default.
+       ModelArgs []string `json:"modelArgs,omitempty"`
+       // Format names the parser for what the agent writes to stdout.
+       Format string `json:"format"`
+       // PromptStdin hands the prompt on stdin instead of the command line. 
It is
+       // what every agent driven from a chat window needs: a package manager's
+       // Windows shim runs through cmd.exe, which ends an argument at the 
first
+       // newline, and a typed message routinely has one.
+       PromptStdin bool `json:"promptStdin,omitempty"`
+}
+
+// CanResume reports whether the agent carries a conversation across turns.
+func (h *Headless) CanResume() bool {
+       return len(h.ResumeArgs) > 0
+}
+
+// NamesSession reports whether the agent is told the id of the session it is
+// starting. One that is not leaves the id to be read out of its output.
+func (h *Headless) NamesSession() bool {
+       for _, arg := range h.Args {
+               if arg == SessionPlaceholder {
+                       return true
+               }
+       }
+       return false
+}
+
+// HeadlessOf is how one agent is driven without a terminal, nil for an agent
+// that publishes no such mode. It reads the fingerprints rather than a host
+// scan, so the answer is known while the agent is not installed.
+func HeadlessOf(agentId string) *Headless {
+       for i := range fingerprints {
+               if fingerprints[i].ID == agentId {
+                       return fingerprints[i].Headless
+               }
+       }
+       return nil
+}
+
+// DrivableAgents are the ids of every agent Gateway can drive.
+func DrivableAgents() []string {
+       result := []string{}
+       for i := range fingerprints {
+               if fingerprints[i].Headless != nil {
+                       result = append(result, fingerprints[i].ID)
+               }
+       }
+       return result
+}
diff --git a/agentsession/command_unix.go b/agentsession/command_unix.go
new file mode 100644
index 0000000..aee4490
--- /dev/null
+++ b/agentsession/command_unix.go
@@ -0,0 +1,45 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !windows
+
+package agentsession
+
+import (
+       "context"
+       "os/exec"
+       "syscall"
+)
+
+// newCommand runs the launcher in a process group of its own, which is what
+// lets everything it starts be stopped together.
+func newCommand(ctx context.Context, executable string, args []string) 
*exec.Cmd {
+       cmd := exec.Command(executable, args...)
+       cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+       return cmd
+}
+
+// killTree ends the launcher and everything it started. Every one of these
+// agents is a shim in front of a runtime, so killing the shim alone would 
leave
+// the agent running.
+func killTree(cmd *exec.Cmd) {
+       if cmd.Process == nil {
+               return
+       }
+       if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil {
+               syscall.Kill(-pgid, syscall.SIGKILL)
+               return
+       }
+       cmd.Process.Kill()
+}
diff --git a/agentsession/command_windows.go b/agentsession/command_windows.go
new file mode 100644
index 0000000..55b2a59
--- /dev/null
+++ b/agentsession/command_windows.go
@@ -0,0 +1,97 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build windows
+
+package agentsession
+
+import (
+       "context"
+       "os/exec"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "syscall"
+)
+
+// newCommand runs the launcher with its output on a pipe and no console of its
+// own. A package manager's shim is a batch file, which Windows cannot start
+// directly, so those go through cmd.exe.
+func newCommand(ctx context.Context, executable string, args []string) 
*exec.Cmd {
+       switch strings.ToLower(filepath.Ext(executable)) {
+       case ".cmd", ".bat":
+               cmd := exec.Command("cmd.exe")
+               cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, 
CmdLine: batchLine(executable, args)}
+               return cmd
+       default:
+               cmd := exec.Command(executable, args...)
+               cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+               return cmd
+       }
+}
+
+// batchLine writes the command line for a shim run through cmd.exe, which 
parses
+// what it is given a second time: os/exec quotes for the program's own 
runtime,
+// and an argument that cmd.exe would read as punctuation - "&" and its like -
+// survives that only inside quotes. So every argument is quoted here, whether 
it
+// needs it or not, and the whole command is wrapped for "/s", which takes what
+// is between the outer quotes literally.
+func batchLine(executable string, args []string) string {
+       line := quoteForCmd(executable)
+       for _, arg := range args {
+               line += " " + quoteForCmd(arg)
+       }
+       return `cmd.exe /s /c "` + line + `"`
+}
+
+// quoteForCmd wraps one argument in quotes, escaping the quotes and 
backslashes
+// inside it the way a program's own runtime unpicks them.
+func quoteForCmd(arg string) string {
+       quoted := strings.Builder{}
+       quoted.WriteByte('"')
+
+       backslashes := 0
+       for i := 0; i < len(arg); i++ {
+               switch character := arg[i]; character {
+               case '\\':
+                       backslashes++
+               case '"':
+                       // The backslashes before a quote are doubled, then the 
quote itself
+                       // is escaped with one more.
+                       quoted.WriteString(strings.Repeat(`\`, backslashes*2+1))
+                       quoted.WriteByte('"')
+                       backslashes = 0
+               default:
+                       quoted.WriteString(strings.Repeat(`\`, backslashes))
+                       quoted.WriteByte(character)
+                       backslashes = 0
+               }
+       }
+       // Trailing backslashes would otherwise escape the closing quote.
+       quoted.WriteString(strings.Repeat(`\`, backslashes*2))
+       quoted.WriteByte('"')
+       return quoted.String()
+}
+
+// killTree ends the launcher and everything it started. Every one of these
+// agents is a shim in front of a runtime, so killing the shim alone would 
leave
+// the agent running.
+func killTree(cmd *exec.Cmd) {
+       if cmd.Process == nil {
+               return
+       }
+       kill := exec.Command("taskkill", "/F", "/T", "/PID", 
strconv.Itoa(cmd.Process.Pid))
+       kill.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+       kill.Run()
+}
diff --git a/agentsession/event.go b/agentsession/event.go
new file mode 100644
index 0000000..57a663e
--- /dev/null
+++ b/agentsession/event.go
@@ -0,0 +1,80 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import "time"
+
+// EventType is what one piece of an agent's answer is.
+type EventType string
+
+const (
+       // EventPrompt is what was asked. It is on the same feed as the answer 
so a
+       // page that reconnects reads the whole conversation back from one 
place.
+       EventPrompt     EventType = "prompt"
+       EventText       EventType = "text"
+       EventThinking   EventType = "thinking"
+       EventToolUse    EventType = "toolUse"
+       EventToolResult EventType = "toolResult"
+       EventUsage      EventType = "usage"
+       EventDone       EventType = "done"
+       EventError      EventType = "error"
+)
+
+// Event is one piece of what an agent said, in a shape every agent is 
flattened
+// into. The parsers own the differences so nothing downstream - the page, the
+// chat bridges, the records - has to know which agent produced it.
+type Event struct {
+       Type EventType `json:"type"`
+       // Seq counts the events of one session from the first. A page that 
lost its
+       // connection says which one it had, and is sent only what came after, 
so a
+       // reconnect does not repeat the conversation.
+       Seq         int64  `json:"seq"`
+       CreatedTime string `json:"createdTime"`
+
+       // Text is the answer for EventText and EventThinking, and the message 
for
+       // EventError.
+       Text string `json:"text,omitempty"`
+
+       ToolName  string `json:"toolName,omitempty"`
+       ToolUseId string `json:"toolUseId,omitempty"`
+       // ToolInput is the arguments the agent called the tool with, as JSON. 
It is
+       // what an approval prompt has to show for a decision to mean anything.
+       ToolInput string `json:"toolInput,omitempty"`
+
+       Model string `json:"model,omitempty"`
+       // NativeId is the agent's own id for this conversation, which is what a
+       // later turn resumes. Reported by the parser for an agent Gateway 
cannot
+       // name a session for up front.
+       NativeId string `json:"nativeId,omitempty"`
+
+       InputTokens  int     `json:"inputTokens,omitempty"`
+       OutputTokens int     `json:"outputTokens,omitempty"`
+       CostUsd      float64 `json:"costUsd,omitempty"`
+       DurationMs   int64   `json:"durationMs,omitempty"`
+}
+
+func newEvent(eventType EventType) Event {
+       return Event{Type: eventType, CreatedTime: 
time.Now().Format(time.RFC3339Nano)}
+}
+
+func textEvent(eventType EventType, text string) Event {
+       event := newEvent(eventType)
+       event.Text = text
+       return event
+}
+
+func errorEvent(err error) Event {
+       return textEvent(EventError, err.Error())
+}
diff --git a/agentsession/parse.go b/agentsession/parse.go
new file mode 100644
index 0000000..2948939
--- /dev/null
+++ b/agentsession/parse.go
@@ -0,0 +1,66 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "bufio"
+       "encoding/json"
+       "io"
+)
+
+// The parsers, named by a fingerprint's "format".
+const (
+       // FormatClaudeStreamJson is "--output-format stream-json", which the 
Claude
+       // Code family all speak.
+       FormatClaudeStreamJson = "claude-stream-json"
+       // FormatCodexJson is "codex exec --json".
+       FormatCodexJson = "codex-json"
+       // FormatText is whatever the agent prints, taken as the answer. It is 
the
+       // fallback for an agent with no machine-readable mode, and loses the 
tool
+       // calls: those are still recorded by the monitor, just not shown here.
+       FormatText = "text"
+)
+
+// maxLine is the longest line a parser accepts. One line can carry a whole 
file
+// an agent just read, so the default scanner limit is far too small.
+const maxLine = 8 * 1024 * 1024
+
+func parse(format string, reader io.Reader, emit func(Event)) error {
+       scanner := bufio.NewScanner(reader)
+       scanner.Buffer(make([]byte, 0, 64*1024), maxLine)
+
+       switch format {
+       case FormatClaudeStreamJson:
+               return parseClaude(scanner, emit)
+       case FormatCodexJson:
+               return parseCodex(scanner, emit)
+       default:
+               return parseText(scanner, emit)
+       }
+}
+
+// jsonString renders a value that an agent may write either as a string or as
+// something structured, which is how tool arguments and tool results arrive.
+func jsonString(raw json.RawMessage) string {
+       if len(raw) == 0 {
+               return ""
+       }
+
+       text := ""
+       if err := json.Unmarshal(raw, &text); err == nil {
+               return text
+       }
+       return string(raw)
+}
diff --git a/agentsession/parse_claude.go b/agentsession/parse_claude.go
new file mode 100644
index 0000000..974ffab
--- /dev/null
+++ b/agentsession/parse_claude.go
@@ -0,0 +1,175 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "bufio"
+       "encoding/json"
+       "errors"
+       "strings"
+)
+
+// claudeLine is one record of "--output-format stream-json". Only the fields
+// Gateway shows are named; the rest are ignored, which is what keeps this
+// working across the releases that add to them.
+type claudeLine struct {
+       Type      string `json:"type"`
+       Subtype   string `json:"subtype"`
+       SessionId string `json:"session_id"`
+       Model     string `json:"model"`
+
+       Message *claudeMessage `json:"message"`
+
+       // The closing record of a run.
+       IsError      bool         `json:"is_error"`
+       Result       string       `json:"result"`
+       TotalCostUsd float64      `json:"total_cost_usd"`
+       DurationMs   int64        `json:"duration_ms"`
+       Usage        *claudeUsage `json:"usage"`
+}
+
+type claudeMessage struct {
+       Model   string          `json:"model"`
+       Content json.RawMessage `json:"content"`
+       Usage   *claudeUsage    `json:"usage"`
+}
+
+type claudeBlock struct {
+       Type string `json:"type"`
+
+       Text     string `json:"text"`
+       Thinking string `json:"thinking"`
+
+       Id    string          `json:"id"`
+       Name  string          `json:"name"`
+       Input json.RawMessage `json:"input"`
+
+       ToolUseId string          `json:"tool_use_id"`
+       Content   json.RawMessage `json:"content"`
+}
+
+type claudeUsage struct {
+       InputTokens  int `json:"input_tokens"`
+       OutputTokens int `json:"output_tokens"`
+}
+
+func parseClaude(scanner *bufio.Scanner, emit func(Event)) error {
+       failure := ""
+       // The agent names its session on every record it writes, and says so 
several
+       // times before it answers. It is reported once.
+       named := false
+       for scanner.Scan() {
+               line := strings.TrimSpace(scanner.Text())
+               if line == "" || line[0] != '{' {
+                       continue
+               }
+
+               record := claudeLine{}
+               if err := json.Unmarshal([]byte(line), &record); err != nil {
+                       continue
+               }
+
+               if !named && record.SessionId != "" {
+                       named = true
+                       event := newEvent(EventUsage)
+                       event.NativeId = record.SessionId
+                       event.Model = record.Model
+                       emit(event)
+               }
+
+               switch record.Type {
+               case "assistant", "user":
+                       emitClaudeBlocks(record, emit)
+               case "result":
+                       failure = emitClaudeResult(record, emit)
+               }
+       }
+
+       if err := scanner.Err(); err != nil {
+               return err
+       }
+       if failure != "" {
+               return errors.New(failure)
+       }
+       return nil
+}
+
+func emitClaudeBlocks(record claudeLine, emit func(Event)) {
+       if record.Message == nil {
+               return
+       }
+
+       blocks := []claudeBlock{}
+       if err := json.Unmarshal(record.Message.Content, &blocks); err != nil {
+               // A message whose content is a bare string is the whole answer.
+               if text := jsonString(record.Message.Content); text != "" {
+                       emit(textEvent(EventText, text))
+               }
+               return
+       }
+
+       for _, block := range blocks {
+               switch block.Type {
+               case "text":
+                       if block.Text != "" {
+                               event := textEvent(EventText, block.Text)
+                               event.Model = record.Message.Model
+                               emit(event)
+                       }
+               case "thinking":
+                       if block.Thinking != "" {
+                               emit(textEvent(EventThinking, block.Thinking))
+                       }
+               case "tool_use":
+                       event := newEvent(EventToolUse)
+                       event.ToolName = block.Name
+                       event.ToolUseId = block.Id
+                       event.ToolInput = jsonString(block.Input)
+                       emit(event)
+               case "tool_result":
+                       event := newEvent(EventToolResult)
+                       event.ToolUseId = block.ToolUseId
+                       event.Text = jsonString(block.Content)
+                       emit(event)
+               }
+       }
+}
+
+// emitClaudeResult reports the closing record and returns why the run failed,
+// empty when it did not.
+func emitClaudeResult(record claudeLine, emit func(Event)) string {
+       event := newEvent(EventUsage)
+       event.CostUsd = record.TotalCostUsd
+       event.DurationMs = record.DurationMs
+       if record.Usage != nil {
+               event.InputTokens = record.Usage.InputTokens
+               event.OutputTokens = record.Usage.OutputTokens
+       }
+       // A run that reported nothing it cost says nothing here either.
+       if event.CostUsd > 0 || event.DurationMs > 0 || event.InputTokens > 0 
|| event.OutputTokens > 0 {
+               emit(event)
+       }
+
+       if !record.IsError {
+               return ""
+       }
+       if record.Result != "" {
+               return record.Result
+       }
+       if record.Subtype != "" {
+               return record.Subtype
+       }
+       return "the agent ended with an error"
+}
diff --git a/agentsession/parse_codex.go b/agentsession/parse_codex.go
new file mode 100644
index 0000000..4e2ec37
--- /dev/null
+++ b/agentsession/parse_codex.go
@@ -0,0 +1,165 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "bufio"
+       "encoding/json"
+       "errors"
+       "strings"
+)
+
+// codexLine is one record of "codex exec --json". Codex has written two shapes
+// of these: the thread-and-item one, and an older one that wrapped everything
+// in "msg". Both are read, because which one an installation speaks depends on
+// its version rather than on anything Gateway controls.
+type codexLine struct {
+       Type     string      `json:"type"`
+       ThreadId string      `json:"thread_id"`
+       Item     *codexItem  `json:"item"`
+       Usage    *codexUsage `json:"usage"`
+       Error    *codexError `json:"error"`
+       Message  string      `json:"message"`
+
+       Msg *codexItem `json:"msg"`
+}
+
+type codexItem struct {
+       Type     string `json:"type"`
+       ItemType string `json:"item_type"`
+
+       Text    string `json:"text"`
+       Message string `json:"message"`
+
+       Command          string `json:"command"`
+       AggregatedOutput string `json:"aggregated_output"`
+
+       Server string          `json:"server"`
+       Tool   string          `json:"tool"`
+       Args   json.RawMessage `json:"arguments"`
+}
+
+type codexUsage struct {
+       InputTokens  int `json:"input_tokens"`
+       OutputTokens int `json:"output_tokens"`
+}
+
+type codexError struct {
+       Message string `json:"message"`
+}
+
+func parseCodex(scanner *bufio.Scanner, emit func(Event)) error {
+       failure := ""
+       for scanner.Scan() {
+               line := strings.TrimSpace(scanner.Text())
+               if line == "" || line[0] != '{' {
+                       continue
+               }
+
+               record := codexLine{}
+               if err := json.Unmarshal([]byte(line), &record); err != nil {
+                       continue
+               }
+
+               switch {
+               case record.Type == "thread.started" && record.ThreadId != "":
+                       event := newEvent(EventUsage)
+                       event.NativeId = record.ThreadId
+                       emit(event)
+               // Only a completed item is reported: the started and updated 
ones carry
+               // the same text again as it grows, and showing each would 
repeat it.
+               case record.Type == "item.completed" && record.Item != nil:
+                       emitCodexItem(record.Item, emit)
+               case record.Type == "turn.completed":
+                       if record.Usage != nil {
+                               event := newEvent(EventUsage)
+                               event.InputTokens = record.Usage.InputTokens
+                               event.OutputTokens = record.Usage.OutputTokens
+                               emit(event)
+                       }
+               case record.Type == "turn.failed" || record.Type == "error":
+                       failure = codexFailure(record)
+               case record.Msg != nil:
+                       emitCodexItem(record.Msg, emit)
+               }
+       }
+
+       if err := scanner.Err(); err != nil {
+               return err
+       }
+       if failure != "" {
+               return errors.New(failure)
+       }
+       return nil
+}
+
+func emitCodexItem(item *codexItem, emit func(Event)) {
+       itemType := item.ItemType
+       if itemType == "" {
+               itemType = item.Type
+       }
+
+       switch itemType {
+       case "agent_message":
+               if text := firstOf(item.Text, item.Message); text != "" {
+                       emit(textEvent(EventText, text))
+               }
+       case "reasoning", "agent_reasoning":
+               if text := firstOf(item.Text, item.Message); text != "" {
+                       emit(textEvent(EventThinking, text))
+               }
+       case "command_execution", "exec_command_begin":
+               event := newEvent(EventToolUse)
+               event.ToolName = "Shell"
+               event.ToolInput = item.Command
+               emit(event)
+               if item.AggregatedOutput != "" {
+                       emit(textEvent(EventToolResult, item.AggregatedOutput))
+               }
+       case "mcp_tool_call":
+               event := newEvent(EventToolUse)
+               event.ToolName = strings.TrimPrefix(item.Server+"."+item.Tool, 
".")
+               event.ToolInput = jsonString(item.Args)
+               emit(event)
+       case "file_change", "patch_apply_begin":
+               event := newEvent(EventToolUse)
+               event.ToolName = "Edit"
+               event.ToolInput = firstOf(item.Text, item.Message)
+               emit(event)
+       case "error":
+               if text := firstOf(item.Message, item.Text); text != "" {
+                       emit(textEvent(EventError, text))
+               }
+       }
+}
+
+func codexFailure(record codexLine) string {
+       if record.Error != nil && record.Error.Message != "" {
+               return record.Error.Message
+       }
+       if record.Message != "" {
+               return record.Message
+       }
+       return "the agent ended with an error"
+}
+
+func firstOf(values ...string) string {
+       for _, value := range values {
+               if value != "" {
+                       return value
+               }
+       }
+       return ""
+}
diff --git a/agentsession/parse_text.go b/agentsession/parse_text.go
new file mode 100644
index 0000000..76482aa
--- /dev/null
+++ b/agentsession/parse_text.go
@@ -0,0 +1,38 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "bufio"
+       "strings"
+)
+
+// parseText takes whatever the agent printed as the answer. Lines are sent on
+// as they arrive rather than gathered up, so a long answer appears while it is
+// being written instead of all at once at the end.
+func parseText(scanner *bufio.Scanner, emit func(Event)) error {
+       said := false
+       for scanner.Scan() {
+               line := scanner.Text()
+               // The leading blank lines are the agent clearing its own 
progress
+               // output, and are not part of what it said.
+               if !said && strings.TrimSpace(line) == "" {
+                       continue
+               }
+               said = true
+               emit(textEvent(EventText, line+"\n"))
+       }
+       return scanner.Err()
+}
diff --git a/agentsession/runner.go b/agentsession/runner.go
new file mode 100644
index 0000000..9eb4ea2
--- /dev/null
+++ b/agentsession/runner.go
@@ -0,0 +1,202 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "bytes"
+       "context"
+       "errors"
+       "fmt"
+       "io"
+       "os"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agenthome"
+)
+
+// turnTimeout caps one exchange. An agent driven from a chat window has nobody
+// watching the console it would otherwise hang in, so a turn that goes quiet 
is
+// ended rather than left holding the session forever.
+const turnTimeout = 30 * time.Minute
+
+// stderrLimit is how much of a failed run's diagnostics is kept for the error
+// message. Agents are talkative on the way down and only the tail is useful.
+const stderrLimit = 4000
+
+// run carries out one exchange and reports what went wrong, if anything.
+func run(ctx context.Context, headless *agent.Headless, session Session, 
prompt string, emit func(Event)) error {
+       launch := agent.LaunchOf(agent.Installation{
+               AgentId: session.AgentId,
+               Path:    session.AgentPath,
+               Owner:   session.Owner,
+       })
+       if launch.Executable == "" {
+               return errors.New("no launcher was found for this installation")
+       }
+
+       // A command line cannot carry a newline through a package manager's 
Windows
+       // shim - cmd.exe ends the argument there - and a message typed in a 
chat
+       // window routinely has one. An agent whose prompt goes on the command 
line
+       // is therefore held to a single line rather than quietly losing the 
rest.
+       if !headless.PromptStdin && strings.ContainsAny(prompt, "\r\n") {
+               return errors.New("this agent takes its prompt on the command 
line, which cannot carry more than one line")
+       }
+
+       workDir, err := resolveWorkDir(session.WorkDir)
+       if err != nil {
+               return err
+       }
+
+       ctx, cancel := context.WithTimeout(ctx, turnTimeout)
+       defer cancel()
+
+       args := buildArgs(headless, session, prompt)
+       cmd := newCommand(ctx, launch.Executable, args)
+       cmd.Dir = workDir
+       cmd.Env = os.Environ()
+
+       stdout, err := cmd.StdoutPipe()
+       if err != nil {
+               return err
+       }
+       stderr := &boundedBuffer{limit: stderrLimit}
+       cmd.Stderr = stderr
+
+       // The prompt goes on stdin wherever the agent reads it there: it is 
the one
+       // part of the command line that carries arbitrary text, and keeping it 
off
+       // removes every quoting question with it. Stdin is closed either way, 
so an
+       // agent that decides to ask something gets an answer instead of 
waiting.
+       stdin, err := cmd.StdinPipe()
+       if err != nil {
+               return err
+       }
+       go func() {
+               defer stdin.Close()
+               if headless.PromptStdin {
+                       io.WriteString(stdin, prompt)
+               }
+       }()
+
+       if err := cmd.Start(); err != nil {
+               return err
+       }
+
+       // Killing the process itself leaves what it started behind - every one 
of
+       // these agents is a launcher in front of a runtime - so the tree goes.
+       stopped := make(chan struct{})
+       go func() {
+               select {
+               case <-ctx.Done():
+                       killTree(cmd)
+               case <-stopped:
+               }
+       }()
+
+       parseErr := parse(headless.Format, stdout, emit)
+       waitErr := cmd.Wait()
+       close(stopped)
+
+       if ctxErr := ctx.Err(); ctxErr != nil {
+               if errors.Is(ctxErr, context.DeadlineExceeded) {
+                       return fmt.Errorf("the agent was still running after %s 
and was stopped", turnTimeout)
+               }
+               return errors.New("stopped")
+       }
+       if waitErr != nil {
+               if detail := strings.TrimSpace(stderr.String()); detail != "" {
+                       return fmt.Errorf("%s: %s", waitErr, detail)
+               }
+               return waitErr
+       }
+       return parseErr
+}
+
+// buildArgs writes the command line for one turn. A session the agent can 
carry
+// on is resumed; one it cannot starts over, which is the honest behaviour for 
an
+// agent that publishes no way to continue.
+func buildArgs(headless *agent.Headless, session Session, prompt string) 
[]string {
+       template := headless.Args
+       if session.NativeId != "" && session.Turns > 0 && headless.CanResume() {
+               template = headless.ResumeArgs
+       }
+
+       args := fill(template, session, prompt, headless.PromptStdin)
+       if session.Model != "" && len(headless.ModelArgs) > 0 {
+               args = append(args, fill(headless.ModelArgs, session, prompt, 
headless.PromptStdin)...)
+       }
+       return args
+}
+
+// fill replaces the placeholders, each of which is a whole argument. A prompt
+// handed over on stdin drops out of the command line rather than being 
repeated
+// on it.
+func fill(template []string, session Session, prompt string, promptOnStdin 
bool) []string {
+       args := make([]string, 0, len(template))
+       for _, arg := range template {
+               switch arg {
+               case agent.PromptPlaceholder:
+                       if !promptOnStdin {
+                               args = append(args, prompt)
+                       }
+               case agent.SessionPlaceholder:
+                       args = append(args, session.NativeId)
+               case agent.ModelPlaceholder:
+                       args = append(args, session.Model)
+               default:
+                       args = append(args, arg)
+               }
+       }
+       return args
+}
+
+// resolveWorkDir is the directory the agent runs in. An empty one is the home 
of
+// the account Gateway runs as, which is where an agent started by hand would
+// have been.
+func resolveWorkDir(workDir string) (string, error) {
+       if workDir == "" {
+               return agenthome.Resolve("")
+       }
+
+       info, err := os.Stat(workDir)
+       if err != nil {
+               return "", fmt.Errorf("the working directory cannot be opened: 
%w", err)
+       }
+       if !info.IsDir() {
+               return "", fmt.Errorf("%s is not a directory", workDir)
+       }
+       return workDir, nil
+}
+
+// boundedBuffer keeps the tail of what was written to it.
+type boundedBuffer struct {
+       limit int
+       buf   bytes.Buffer
+}
+
+func (b *boundedBuffer) Write(p []byte) (int, error) {
+       written, err := b.buf.Write(p)
+       if b.buf.Len() > b.limit {
+               kept := b.buf.Bytes()[b.buf.Len()-b.limit:]
+               trimmed := bytes.NewBuffer(append([]byte{}, kept...))
+               b.buf = *trimmed
+       }
+       return written, err
+}
+
+func (b *boundedBuffer) String() string {
+       return b.buf.String()
+}
diff --git a/agentsession/session.go b/agentsession/session.go
new file mode 100644
index 0000000..8c53763
--- /dev/null
+++ b/agentsession/session.go
@@ -0,0 +1,100 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package agentsession drives an installed agent on somebody's behalf: it 
hands
+// one prompt to the agent's own non-interactive mode and flattens what comes
+// back into events. Nothing here knows where the prompt came from, so the same
+// session is driven from the web page and from a chat platform.
+//
+// Driving an agent this way runs the real agent, with the configuration and 
the
+// hooks Gateway already wrote into it. Its transcript, its usage and its tool
+// permissions therefore keep working exactly as they do when somebody types at
+// it, and none of that is reimplemented here.
+package agentsession
+
+import (
+       "strings"
+       "time"
+)
+
+// State is where a session is in its turn.
+type State string
+
+const (
+       StateIdle    State = "idle"
+       StateRunning State = "running"
+       StateFailed  State = "failed"
+)
+
+// Spec is what a session is opened with.
+type Spec struct {
+       AgentId   string `json:"agentId"`
+       AgentPath string `json:"agentPath"`
+       Owner     string `json:"owner"`
+       // WorkDir is the directory the agent runs in, which is the project it 
acts
+       // on. Empty means the home of the account Gateway runs as.
+       WorkDir string `json:"workDir"`
+       // Model overrides the agent's own default for this session, empty to 
leave
+       // it alone.
+       Model string `json:"model"`
+       // Source names who opened the session - "web", or 
"im:<platform>:<user>" -
+       // and is what the records of this session are attributed to.
+       Source string `json:"source"`
+}
+
+// Session is one conversation with one installed agent.
+type Session struct {
+       Id string `json:"id"`
+       Spec
+
+       // NativeId is the agent's own id for this conversation, which is what 
the
+       // next turn resumes. It is either handed to the agent when the session 
is
+       // opened, for one that takes it, or read out of the first turn's 
output.
+       NativeId string `json:"nativeId"`
+       // Resumable is false for an agent that carries nothing across turns, 
so the
+       // page can say that every message stands alone rather than pretending.
+       Resumable bool `json:"resumable"`
+
+       Title       string `json:"title"`
+       State       State  `json:"state"`
+       Turns       int    `json:"turns"`
+       CreatedTime string `json:"createdTime"`
+       UpdatedTime string `json:"updatedTime"`
+       // LastError is why the last turn failed, empty when it did not.
+       LastError string `json:"lastError,omitempty"`
+}
+
+// titleLimit keeps a session's name to something a list and a chat window can
+// both show on one line.
+const titleLimit = 60
+
+// titleFrom names a session after the first thing asked of it, which is what
+// somebody looking at a list of them recognises.
+func titleFrom(prompt string) string {
+       title := ""
+       for _, line := range strings.Split(prompt, "\n") {
+               if trimmed := strings.TrimSpace(line); trimmed != "" {
+                       title = trimmed
+                       break
+               }
+       }
+       if len([]rune(title)) > titleLimit {
+               title = string([]rune(title)[:titleLimit]) + "..."
+       }
+       return title
+}
+
+func now() string {
+       return time.Now().Format(time.RFC3339Nano)
+}
diff --git a/agentsession/store.go b/agentsession/store.go
new file mode 100644
index 0000000..7c4c3a8
--- /dev/null
+++ b/agentsession/store.go
@@ -0,0 +1,361 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentsession
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "sync"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/google/uuid"
+)
+
+var (
+       ErrNoSession   = errors.New("no such session")
+       ErrNotDrivable = errors.New("this agent publishes no non-interactive 
mode, so Gateway cannot drive it")
+       ErrBusy        = errors.New("this session is still answering")
+)
+
+const (
+       // historyLimit caps what one session replays to a page that connects 
while a
+       // turn is already running. A turn that says more than this is 
truncated at
+       // the front rather than kept whole in memory.
+       historyLimit = 4000
+       // feedBuffer is how far one subscriber may fall behind before its 
events are
+       // dropped. Dropping is deliberate: a page nobody is looking at must 
not hold
+       // the agent up.
+       feedBuffer = 1024
+)
+
+// live is one session and whatever is listening to it.
+type live struct {
+       mu          sync.Mutex
+       session     Session
+       events      []Event
+       nextSeq     int64
+       subscribers map[int]chan Event
+       nextId      int
+       cancel      context.CancelFunc
+}
+
+var registry = struct {
+       sync.Mutex
+       sessions map[string]*live
+}{sessions: map[string]*live{}}
+
+// SessionSink is where sessions are kept, so they outlive the process. Storage
+// lives outside this package for the same reason it does for the monitor:
+// driving an agent must not depend on the database.
+type SessionSink func(*Session)
+
+var sessionSink struct {
+       sync.RWMutex
+       sink SessionSink
+}
+
+// SetSessionSink installs the store every change to a session is written to.
+func SetSessionSink(sink SessionSink) {
+       sessionSink.Lock()
+       sessionSink.sink = sink
+       sessionSink.Unlock()
+}
+
+func persist(session Session) {
+       sessionSink.RLock()
+       sink := sessionSink.sink
+       sessionSink.RUnlock()
+       if sink != nil {
+               sink(&session)
+       }
+}
+
+// Open starts a session against one installation. Nothing runs yet: an agent 
is
+// only started when there is something to ask it.
+func Open(spec Spec) (Session, error) {
+       headless := agent.HeadlessOf(spec.AgentId)
+       if headless == nil {
+               return Session{}, ErrNotDrivable
+       }
+
+       entry := &live{
+               session: Session{
+                       Id:          uuid.NewString(),
+                       Spec:        spec,
+                       Resumable:   headless.CanResume(),
+                       State:       StateIdle,
+                       CreatedTime: now(),
+                       UpdatedTime: now(),
+               },
+               subscribers: map[int]chan Event{},
+       }
+       // An agent that takes the id of the session it is starting is told 
ours, so
+       // the next turn resumes it without anything having to be read back.
+       if headless.NamesSession() {
+               entry.session.NativeId = entry.session.Id
+       }
+
+       registry.Lock()
+       registry.sessions[entry.session.Id] = entry
+       registry.Unlock()
+
+       persist(entry.session)
+       return entry.session, nil
+}
+
+// Restore puts a session kept from an earlier run back in the registry, idle.
+func Restore(session Session) {
+       session.State = StateIdle
+       entry := &live{session: session, subscribers: map[int]chan Event{}}
+
+       registry.Lock()
+       registry.sessions[session.Id] = entry
+       registry.Unlock()
+}
+
+func lookup(id string) (*live, error) {
+       registry.Lock()
+       entry, ok := registry.sessions[id]
+       registry.Unlock()
+       if !ok {
+               return nil, ErrNoSession
+       }
+       return entry, nil
+}
+
+// Get is one session as it stands now.
+func Get(id string) (Session, error) {
+       entry, err := lookup(id)
+       if err != nil {
+               return Session{}, err
+       }
+
+       entry.mu.Lock()
+       defer entry.mu.Unlock()
+       return entry.session, nil
+}
+
+// List is every session, newest first by the caller's own ordering.
+func List() []Session {
+       registry.Lock()
+       entries := make([]*live, 0, len(registry.sessions))
+       for _, entry := range registry.sessions {
+               entries = append(entries, entry)
+       }
+       registry.Unlock()
+
+       result := make([]Session, 0, len(entries))
+       for _, entry := range entries {
+               entry.mu.Lock()
+               result = append(result, entry.session)
+               entry.mu.Unlock()
+       }
+       return result
+}
+
+// Close ends a session: whatever it is running is stopped, and it is dropped
+// from the registry. What was stored of it is the caller's to remove.
+func Close(id string) error {
+       entry, err := lookup(id)
+       if err != nil {
+               return err
+       }
+
+       entry.mu.Lock()
+       if entry.cancel != nil {
+               entry.cancel()
+       }
+       for _, feed := range entry.subscribers {
+               close(feed)
+       }
+       entry.subscribers = map[int]chan Event{}
+       entry.mu.Unlock()
+
+       registry.Lock()
+       delete(registry.sessions, id)
+       registry.Unlock()
+       return nil
+}
+
+// Interrupt stops the turn a session is in the middle of, leaving the session
+// itself open for the next one.
+func Interrupt(id string) error {
+       entry, err := lookup(id)
+       if err != nil {
+               return err
+       }
+
+       entry.mu.Lock()
+       defer entry.mu.Unlock()
+       if entry.cancel == nil {
+               return nil
+       }
+       entry.cancel()
+       return nil
+}
+
+// Subscribe hands back what the session has said so far and a feed of what it
+// says next, so a page that connects in the middle of a turn misses nothing.
+// Only what came after "seen" is replayed, which is how a page that reconnects
+// picks up where it left off instead of reading the conversation twice.
+func Subscribe(id string, seen int64) (int, <-chan Event, []Event, error) {
+       entry, err := lookup(id)
+       if err != nil {
+               return 0, nil, nil, err
+       }
+
+       entry.mu.Lock()
+       defer entry.mu.Unlock()
+
+       entry.nextId++
+       subscriberId := entry.nextId
+       feed := make(chan Event, feedBuffer)
+       entry.subscribers[subscriberId] = feed
+
+       history := []Event{}
+       for _, event := range entry.events {
+               if event.Seq > seen {
+                       history = append(history, event)
+               }
+       }
+       return subscriberId, feed, history, nil
+}
+
+// Unsubscribe drops one feed. Closing is left to this side so that a publish
+// racing with it cannot write to a closed channel.
+func Unsubscribe(id string, subscriberId int) {
+       entry, err := lookup(id)
+       if err != nil {
+               return
+       }
+
+       entry.mu.Lock()
+       defer entry.mu.Unlock()
+       if feed, ok := entry.subscribers[subscriberId]; ok {
+               delete(entry.subscribers, subscriberId)
+               close(feed)
+       }
+}
+
+// publish records one event against the session and hands it to every 
listener.
+func (entry *live) publish(event Event) {
+       entry.mu.Lock()
+       defer entry.mu.Unlock()
+
+       entry.nextSeq++
+       event.Seq = entry.nextSeq
+       entry.events = append(entry.events, event)
+       if len(entry.events) > historyLimit {
+               entry.events = entry.events[len(entry.events)-historyLimit:]
+       }
+       for _, feed := range entry.subscribers {
+               select {
+               case feed <- event:
+               default:
+               }
+       }
+}
+
+// Send asks the session's agent one thing. It returns as soon as the agent has
+// been started: the answer arrives on the feed.
+func Send(id, prompt string) (Session, error) {
+       entry, err := lookup(id)
+       if err != nil {
+               return Session{}, err
+       }
+
+       entry.mu.Lock()
+       if entry.session.State == StateRunning {
+               entry.mu.Unlock()
+               return Session{}, ErrBusy
+       }
+
+       headless := agent.HeadlessOf(entry.session.AgentId)
+       if headless == nil {
+               entry.mu.Unlock()
+               return Session{}, ErrNotDrivable
+       }
+
+       if entry.session.Title == "" {
+               entry.session.Title = titleFrom(prompt)
+       }
+       entry.session.State = StateRunning
+       entry.session.LastError = ""
+       entry.session.UpdatedTime = now()
+       session := entry.session
+
+       ctx, cancel := context.WithCancel(context.Background())
+       entry.cancel = cancel
+       entry.mu.Unlock()
+
+       entry.publish(textEvent(EventPrompt, prompt))
+       persist(session)
+
+       go entry.turn(ctx, cancel, headless, prompt)
+       return session, nil
+}
+
+// turn runs one exchange to its end and leaves the session ready for the next.
+func (entry *live) turn(ctx context.Context, cancel context.CancelFunc, 
headless *agent.Headless, prompt string) {
+       defer cancel()
+
+       entry.mu.Lock()
+       session := entry.session
+       entry.mu.Unlock()
+
+       nativeId := ""
+       runErr := run(ctx, headless, session, prompt, func(event Event) {
+               if event.NativeId != "" {
+                       nativeId = event.NativeId
+               }
+               entry.publish(event)
+       })
+
+       entry.mu.Lock()
+       entry.cancel = nil
+       entry.session.Turns++
+       entry.session.UpdatedTime = now()
+       // An agent that names its own conversation only says so once it has 
started
+       // one, so the id is taken from the first turn that reported it.
+       if nativeId != "" && entry.session.NativeId == "" {
+               entry.session.NativeId = nativeId
+       }
+       if runErr != nil {
+               entry.session.State = StateFailed
+               entry.session.LastError = runErr.Error()
+       } else {
+               entry.session.State = StateIdle
+       }
+       session = entry.session
+       entry.mu.Unlock()
+
+       if runErr != nil {
+               entry.publish(errorEvent(runErr))
+       }
+       done := newEvent(EventDone)
+       done.NativeId = session.NativeId
+       entry.publish(done)
+
+       persist(session)
+}
+
+// Drivable reports whether an agent can be driven, and why not when it cannot.
+func Drivable(agentId string) error {
+       if agent.HeadlessOf(agentId) == nil {
+               return fmt.Errorf("%s: %w", agent.DisplayNameOf(agentId), 
ErrNotDrivable)
+       }
+       return nil
+}
diff --git a/controllers/agent_session.go b/controllers/agent_session.go
new file mode 100644
index 0000000..07455f9
--- /dev/null
+++ b/controllers/agent_session.go
@@ -0,0 +1,297 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "encoding/json"
+       "fmt"
+       "net/http"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/agent"
+       "github.com/apache/casbin-gateway/agentsession"
+       "github.com/apache/casbin-gateway/object"
+)
+
+// sessionStreamHeartbeat keeps a stream that has gone quiet from being closed 
by
+// whatever is between the page and Gateway.
+const sessionStreamHeartbeat = 25 * time.Second
+
+// drivableAgent is one installation the chat page can open a session against.
+type drivableAgent struct {
+       AgentId string `json:"agentId"`
+       Name    string `json:"name"`
+       Path    string `json:"path"`
+       Owner   string `json:"owner"`
+       // Resumable is false for an agent that carries nothing from one turn 
to the
+       // next, which the page says out loud rather than letting somebody find 
out.
+       Resumable bool `json:"resumable"`
+}
+
+// GetDrivableAgents lists the installations Gateway can hand a prompt to. An
+// agent that publishes no non-interactive mode is left out rather than shown 
as
+// something that will work later.
+func (c *ApiController) GetDrivableAgents() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       installations, err := agent.Scan(false)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       result := []*drivableAgent{}
+       for _, installation := range installations {
+               headless := agent.HeadlessOf(installation.AgentId)
+               if headless == nil {
+                       continue
+               }
+               result = append(result, &drivableAgent{
+                       AgentId:   installation.AgentId,
+                       Name:      installation.Name,
+                       Path:      installation.Path,
+                       Owner:     installation.Owner,
+                       Resumable: headless.CanResume(),
+               })
+       }
+       c.ResponseOk(result)
+}
+
+// GetDrivenSessions lists the conversations Gateway is driving, most recently
+// used first.
+func (c *ApiController) GetDrivenSessions() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       sessions := agentsession.List()
+       sort.Slice(sessions, func(i, j int) bool {
+               return sessions[i].UpdatedTime > sessions[j].UpdatedTime
+       })
+       c.ResponseOk(sessions)
+}
+
+// OpenDrivenSession starts a conversation against one installation. Nothing 
runs
+// until it is asked something.
+func (c *ApiController) OpenDrivenSession() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var request struct {
+               AgentId string `json:"agentId"`
+               Path    string `json:"path"`
+               Owner   string `json:"owner"`
+               WorkDir string `json:"workDir"`
+               Model   string `json:"model"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+
+       installation, err := findInstallation(request.AgentId, request.Path, 
request.Owner)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       session, err := agentsession.Open(agentsession.Spec{
+               AgentId:   installation.AgentId,
+               AgentPath: installation.Path,
+               Owner:     installation.Owner,
+               WorkDir:   strings.TrimSpace(request.WorkDir),
+               Model:     strings.TrimSpace(request.Model),
+               Source:    "web",
+       })
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(session)
+}
+
+// SendDrivenSession asks the session's agent one thing and returns as soon as 
it
+// has been started. What it says arrives on the stream.
+func (c *ApiController) SendDrivenSession() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var request struct {
+               Id     string `json:"id"`
+               Prompt string `json:"prompt"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+       if strings.TrimSpace(request.Prompt) == "" {
+               c.ResponseError("there is nothing to ask")
+               return
+       }
+
+       session, err := agentsession.Send(request.Id, request.Prompt)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(session)
+}
+
+// InterruptDrivenSession stops the turn a session is in the middle of, leaving
+// the session open for the next one.
+func (c *ApiController) InterruptDrivenSession() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       id := c.readSessionId()
+       if id == "" {
+               return
+       }
+       if err := agentsession.Interrupt(id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk()
+}
+
+// CloseDrivenSession ends a conversation and forgets it. The agent keeps its 
own
+// transcript of what was said, which is where the record of it lives.
+func (c *ApiController) CloseDrivenSession() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       id := c.readSessionId()
+       if id == "" {
+               return
+       }
+       if err := agentsession.Close(id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if err := object.DeleteAgentSession(id); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk()
+}
+
+// StreamDrivenSession pushes what a session says as it says it. What it has
+// already said is replayed first, so a page that opens in the middle of a 
turn -
+// or comes back to one - reads the whole conversation from here.
+func (c *ApiController) StreamDrivenSession() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       id := c.Input().Get("id")
+       // The browser reconnects a dropped stream on its own and says which 
event it
+       // had last, so only what came after that is replayed.
+       seen, _ := 
strconv.ParseInt(firstNonEmpty(c.Ctx.Input.Header("Last-Event-ID"), 
c.Input().Get("seen")), 10, 64)
+
+       subscriberId, feed, history, err := agentsession.Subscribe(id, seen)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       defer agentsession.Unsubscribe(id, subscriberId)
+
+       c.EnableRender = false
+       writer := c.Ctx.ResponseWriter
+       writer.Header().Set("Content-Type", "text/event-stream")
+       writer.Header().Set("Cache-Control", "no-cache")
+       writer.Header().Set("Connection", "keep-alive")
+       // An nginx in front of Gateway would otherwise hold every event back 
until
+       // the response ends.
+       writer.Header().Set("X-Accel-Buffering", "no")
+       writer.WriteHeader(http.StatusOK)
+       writer.Flush()
+
+       for _, event := range history {
+               if !writeSessionEvent(writer, event) {
+                       return
+               }
+       }
+
+       ticker := time.NewTicker(sessionStreamHeartbeat)
+       defer ticker.Stop()
+       closed := c.Ctx.Request.Context().Done()
+       for {
+               select {
+               case event, ok := <-feed:
+                       if !ok {
+                               return
+                       }
+                       if !writeSessionEvent(writer, event) {
+                               return
+                       }
+               case <-ticker.C:
+                       if _, err := fmt.Fprint(writer, ": keep-alive\n\n"); 
err != nil {
+                               return
+                       }
+                       writer.Flush()
+               case <-closed:
+                       return
+               }
+       }
+}
+
+func writeSessionEvent(writer interface {
+       Write([]byte) (int, error)
+       Flush()
+}, event agentsession.Event) bool {
+       data, err := json.Marshal(event)
+       if err != nil {
+               return true
+       }
+       if _, err := fmt.Fprintf(writer, "id: %d\nevent: message\ndata: 
%s\n\n", event.Seq, data); err != nil {
+               return false
+       }
+       writer.Flush()
+       return true
+}
+
+func firstNonEmpty(values ...string) string {
+       for _, value := range values {
+               if value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+// readSessionId takes the session out of a request that names nothing else.
+func (c *ApiController) readSessionId() string {
+       var request struct {
+               Id string `json:"id"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil 
{
+               c.ResponseError(err.Error())
+               return ""
+       }
+       if request.Id == "" {
+               c.ResponseError("no session was named")
+               return ""
+       }
+       return request.Id
+}
diff --git a/main.go b/main.go
index e1e0f41..c9460d9 100644
--- a/main.go
+++ b/main.go
@@ -125,6 +125,10 @@ func main() {
        }
        defer agentmonitor.Stop()
 
+       // The conversations Gateway drives on somebody's behalf, put back 
where a
+       // restart left them.
+       object.InitAgentSessions()
+
        // Monitoring is on by default, so the agents already on this host are
        // patched without anyone opening the UI. The scan walks the disk, 
hence the
        // goroutine.
diff --git a/object/agent_session.go b/object/agent_session.go
new file mode 100644
index 0000000..9c7335f
--- /dev/null
+++ b/object/agent_session.go
@@ -0,0 +1,149 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "sort"
+
+       "github.com/apache/casbin-gateway/agentsession"
+       "github.com/beego/beego"
+       "github.com/xorm-io/core"
+)
+
+// AgentSession is one conversation Gateway is driving, kept so that it 
outlives
+// a restart: the agent's own id for it is what a later turn resumes, and 
losing
+// that would strand the conversation inside the agent.
+//
+// What was said is not stored here. The agent keeps its own transcript, which
+// the monitor already reads, and a second copy would only be one more place 
for
+// somebody's prompts to sit.
+type AgentSession struct {
+       Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+       Name  string `xorm:"varchar(100) notnull pk" json:"name"`
+
+       AgentId   string `xorm:"varchar(100) index" json:"agentId"`
+       AgentPath string `xorm:"varchar(500)" json:"agentPath"`
+       AgentUser string `xorm:"varchar(100)" json:"agentUser"`
+       WorkDir   string `xorm:"varchar(500)" json:"workDir"`
+       Model     string `xorm:"varchar(200)" json:"model"`
+       // Source names who opened the session: "web", or 
"im:<platform>:<user>".
+       Source string `xorm:"varchar(200)" json:"source"`
+
+       // NativeId is the agent's own id for the conversation, which is the 
whole
+       // reason a session is stored at all.
+       NativeId  string `xorm:"varchar(200)" json:"nativeId"`
+       Resumable bool   `json:"resumable"`
+
+       Title       string `xorm:"varchar(200)" json:"title"`
+       Turns       int    `json:"turns"`
+       LastError   string `xorm:"varchar(1000)" json:"lastError"`
+       CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+       UpdatedTime string `xorm:"varchar(100) index" json:"updatedTime"`
+}
+
+// agentSessionOwner is who every session belongs to. Gateway serves one 
machine,
+// so that is its admin, the same as an agent's own row.
+const agentSessionOwner = AgentOwner
+
+func toAgentSession(session *agentsession.Session) *AgentSession {
+       return &AgentSession{
+               Owner:       agentSessionOwner,
+               Name:        session.Id,
+               AgentId:     session.AgentId,
+               AgentPath:   session.AgentPath,
+               AgentUser:   session.Owner,
+               WorkDir:     session.WorkDir,
+               Model:       session.Model,
+               Source:      session.Source,
+               NativeId:    session.NativeId,
+               Resumable:   session.Resumable,
+               Title:       session.Title,
+               Turns:       session.Turns,
+               LastError:   session.LastError,
+               CreatedTime: session.CreatedTime,
+               UpdatedTime: session.UpdatedTime,
+       }
+}
+
+func (session *AgentSession) toDriven() agentsession.Session {
+       return agentsession.Session{
+               Id: session.Name,
+               Spec: agentsession.Spec{
+                       AgentId:   session.AgentId,
+                       AgentPath: session.AgentPath,
+                       Owner:     session.AgentUser,
+                       WorkDir:   session.WorkDir,
+                       Model:     session.Model,
+                       Source:    session.Source,
+               },
+               NativeId:    session.NativeId,
+               Resumable:   session.Resumable,
+               Title:       session.Title,
+               Turns:       session.Turns,
+               LastError:   session.LastError,
+               CreatedTime: session.CreatedTime,
+               UpdatedTime: session.UpdatedTime,
+       }
+}
+
+// SaveAgentSession writes one session, inserting it the first time. It is the
+// sink the driver hands every change to, so it must not fail loudly: a session
+// that cannot be stored still answers, it just cannot be resumed after a
+// restart.
+func SaveAgentSession(session *agentsession.Session) {
+       row := toAgentSession(session)
+       affected, err := ormer.Engine.ID(core.PK{row.Owner, 
row.Name}).AllCols().Update(row)
+       if err == nil && affected == 0 {
+               _, err = ormer.Engine.Insert(row)
+       }
+       if err != nil {
+               beego.Error("agent session could not be stored:", err)
+       }
+}
+
+// DeleteAgentSession removes one stored session.
+func DeleteAgentSession(id string) error {
+       _, err := ormer.Engine.ID(core.PK{agentSessionOwner, 
id}).Delete(&AgentSession{})
+       return err
+}
+
+// GetAgentSessions is every stored session, most recently used first.
+func GetAgentSessions() ([]*AgentSession, error) {
+       sessions := []*AgentSession{}
+       if err := ormer.Engine.Where("owner = ?", 
agentSessionOwner).Find(&sessions); err != nil {
+               return nil, err
+       }
+
+       sort.Slice(sessions, func(i, j int) bool {
+               return sessions[i].UpdatedTime > sessions[j].UpdatedTime
+       })
+       return sessions, nil
+}
+
+// InitAgentSessions puts the stored sessions back in the driver and starts
+// keeping it up to date. Nothing is running after this: a restored session 
only
+// holds the agent's own id, and starts an agent again when it is next asked
+// something.
+func InitAgentSessions() {
+       sessions, err := GetAgentSessions()
+       if err != nil {
+               panic(err)
+       }
+       for _, session := range sessions {
+               agentsession.Restore(session.toDriven())
+       }
+
+       agentsession.SetSessionSink(SaveAgentSession)
+}
diff --git a/object/ormer.go b/object/ormer.go
index 4514eb1..3e58bce 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -337,4 +337,11 @@ func (a *Ormer) createTable() {
        if err != nil {
                panic(err)
        }
+
+       // The conversations Gateway is driving on somebody's behalf, kept for 
the
+       // agent's own id for each, which is what carries one across a restart.
+       err = a.Engine.Sync2(new(AgentSession))
+       if err != nil {
+               panic(err)
+       }
 }
diff --git a/routers/router.go b/routers/router.go
index 6cf5a7f..b1fcef4 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -88,6 +88,15 @@ func initAPI() {
        beego.Router("/api/start-agent-instance", &controllers.ApiController{}, 
"POST:StartAgentInstance")
        beego.Router("/api/stop-agent-instance", &controllers.ApiController{}, 
"POST:StopAgentInstance")
        beego.Router("/api/capture-agent-instance-link", 
&controllers.ApiController{}, "POST:CaptureAgentInstanceLink")
+
+       // Driving an agent: Gateway hands it a prompt and reads back what it 
says.
+       beego.Router("/api/get-drivable-agents", &controllers.ApiController{}, 
"GET:GetDrivableAgents")
+       beego.Router("/api/get-driven-sessions", &controllers.ApiController{}, 
"GET:GetDrivenSessions")
+       beego.Router("/api/open-driven-session", &controllers.ApiController{}, 
"POST:OpenDrivenSession")
+       beego.Router("/api/send-driven-session", &controllers.ApiController{}, 
"POST:SendDrivenSession")
+       beego.Router("/api/interrupt-driven-session", 
&controllers.ApiController{}, "POST:InterruptDrivenSession")
+       beego.Router("/api/close-driven-session", &controllers.ApiController{}, 
"POST:CloseDrivenSession")
+       beego.Router("/api/stream-driven-session", 
&controllers.ApiController{}, "GET:StreamDrivenSession")
        beego.Router("/api/get-agent-accounts", &controllers.ApiController{}, 
"GET:GetAgentAccounts")
        beego.Router("/api/save-agent-account", &controllers.ApiController{}, 
"POST:SaveAgentAccount")
        beego.Router("/api/add-agent-account", &controllers.ApiController{}, 
"POST:AddAgentAccount")
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 16dc9e4..5c338ae 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -38,6 +38,7 @@ import AgentVersionsPage from "@/pages/AgentVersionsPage";
 import AgentsPage from "@/pages/AgentsPage";
 import AuthCallback from "@/pages/AuthCallback";
 import AuthenticityPage from "@/pages/AuthenticityPage";
+import ChatPage from "@/pages/ChatPage";
 import ConnectionsPage from "@/pages/ConnectionsPage";
 import HomePage from "@/pages/HomePage";
 import ImportLinkPage from "@/pages/ImportLinkPage";
@@ -286,6 +287,7 @@ export default function App() {
                   )}
                 />
                 <Route path="/account" element={requireSignin(user => 
<AccountPage account={user} />)} />
+                <Route path="/chat" element={requireSignin(user => <ChatPage 
account={user} />)} />
                 <Route path="/agents" element={requireSignin(user => 
<AgentsPage account={user} />)} />
                 <Route
                   path="/agent-versions"
diff --git a/web/src/backend/DriveBackend.ts b/web/src/backend/DriveBackend.ts
new file mode 100644
index 0000000..fc0eea4
--- /dev/null
+++ b/web/src/backend/DriveBackend.ts
@@ -0,0 +1,69 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {query, request} from "@/backend/request";
+import {ServerUrl} from "@/Setting";
+import type {DrivableAgent, DrivenEvent, DrivenSession} from "@/types";
+
+/** The installations that publish a non-interactive mode, which is all 
Gateway can drive. */
+export function getDrivableAgents() {
+  return request<DrivableAgent[]>("/api/get-drivable-agents");
+}
+
+export function getDrivenSessions() {
+  return request<DrivenSession[]>("/api/get-driven-sessions");
+}
+
+/** Opens a conversation. Nothing runs until it is asked something. */
+export function openDrivenSession(target: {agentId: string; path: string; 
owner: string}, workDir: string, model = "") {
+  return request<DrivenSession>("/api/open-driven-session", "POST", 
{...target, workDir: workDir, model: model});
+}
+
+/** Asks one thing; the answer arrives on the stream, not in this response. */
+export function sendDrivenSession(id: string, prompt: string) {
+  return request<DrivenSession>("/api/send-driven-session", "POST", {id: id, 
prompt: prompt});
+}
+
+export function interruptDrivenSession(id: string) {
+  return request<boolean>("/api/interrupt-driven-session", "POST", {id: id});
+}
+
+export function closeDrivenSession(id: string) {
+  return request<boolean>("/api/close-driven-session", "POST", {id: id});
+}
+
+/**
+ * Opens the feed of one conversation; the browser reconnects on its own and 
the
+ * server replays only what was missed. Returns the function that closes it.
+ */
+export function streamDrivenSession(
+  id: string,
+  handlers: {onEvent: (event: DrivenEvent) => void; onOpen?: () => void; 
onError?: () => void},
+) {
+  const source = new 
EventSource(`${ServerUrl}/api/stream-driven-session${query({id: id})}`, {
+    withCredentials: true,
+  });
+
+  source.addEventListener("message", event => {
+    try {
+      handlers.onEvent(JSON.parse((event as MessageEvent).data) as 
DrivenEvent);
+    } catch {
+      // A malformed event is not worth tearing the feed down for.
+    }
+  });
+  source.onopen = () => handlers.onOpen?.();
+  source.onerror = () => handlers.onError?.();
+
+  return () => source.close();
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 487c024..cd33393 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -831,6 +831,21 @@
     "What was sent": "What was sent",
     "Wrong": "Wrong"
   },
+  "chat": {
+    "Ask an agent on this machine something, and watch it work": "Ask an agent 
on this machine something, and watch it work",
+    "Ask the agent something": "Ask the agent something",
+    "Chat": "Chat",
+    "Each message stands alone": "Each message stands alone",
+    "New session": "New session",
+    "No agent on this machine can be driven": "No agent on this machine can be 
driven",
+    "No session is open": "No session is open",
+    "Nothing asked yet": "Nothing asked yet",
+    "Pick an agent": "Pick an agent",
+    "Thinking": "Thinking",
+    "Working directory": "Working directory",
+    "{count} tokens in": "{count} tokens in",
+    "{count} tokens out": "{count} tokens out"
+  },
   "connector": {
     "API key": "API key",
     "All": "All",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 8f92e19..6654552 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -903,6 +903,21 @@
     "case.vendor-headers.method": 
"这一条只向厂商自己的域名发问,比对的也是该厂商文档里写明的响应头:能说OpenAI兼容的API,不等于自称是OpenAI。真货前面的转售商通常会透传一部分,而从没连过厂商的后端一个也拿不出来;但把响应头抹掉也可能只是干净,所以这一条单独不会判定为异常。响应头列表留空就用该厂商自己的,也可以在这里指定要检查哪些响应头。",
     "case.vendor-headers.question": "厂商自己API的那些响应头回来了吗?"
   },
+  "chat": {
+    "Ask an agent on this machine something, and watch it work": 
"让这台机器上的Agent做事,并看着它做",
+    "Ask the agent something": "问这个Agent点什么",
+    "Chat": "聊天",
+    "Each message stands alone": "每条消息各自独立",
+    "New session": "新建会话",
+    "No agent on this machine can be driven": "这台机器上没有可驱动的Agent",
+    "No session is open": "没有打开的会话",
+    "Nothing asked yet": "还没问过什么",
+    "Pick an agent": "选一个Agent",
+    "Thinking": "思考过程",
+    "Working directory": "工作目录",
+    "{count} tokens in": "输入{count}个token",
+    "{count} tokens out": "输出{count}个token"
+  },
   "connector": {
     "A browser that runs in Browserbase's cloud rather than on this machine, 
for pages that need a real one and a clean address.": 
"跑在Browserbase云端而不是本机的浏览器,适合那些需要真实浏览器和干净出口IP的页面。",
     "A knowledge graph the agent writes to and reads back, so what it learned 
in one session is there in the next.": "一张Agent可以写入和回读的知识图谱,这次会话学到的东西下次还在。",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 2bc0b2f..396797f 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -25,6 +25,7 @@ import {
   PackageCheck,
   Plug,
   Route,
+  SendHorizontal,
   Settings,
   ShieldCheck,
   ShieldHalf,
@@ -69,6 +70,9 @@ export const navGroups: NavGroup[] = [
   // The table of installations is the home screen's own advanced view, linked
   // from it rather than competing with it in the rail.
   {key: "/agents", label: "agent:Advanced view", icon: Table2, path: 
"/agents", adminOnly: true, hidden: true},
+  // Driving an agent is what somebody does with one, so it sits beside the
+  // list of them rather than down among the pages that only report.
+  {key: "/chat", label: "chat:Chat", icon: SendHorizontal, path: "/chat", 
adminOnly: true},
   {
     key: "/agent-versions",
     label: "agent:Agent versions",
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
new file mode 100644
index 0000000..acd5fb8
--- /dev/null
+++ b/web/src/pages/ChatPage.tsx
@@ -0,0 +1,441 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {ArrowUp, MessageSquare, Plus, Square, Trash2, Wrench} from 
"lucide-react";
+import i18next from "i18next";
+
+import * as DriveBackend from "@/backend/DriveBackend";
+import * as Setting from "@/Setting";
+import {AgentIcon} from "@/components/AgentIcon";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Fold} from "@/components/shared/fold";
+import {Markdown} from "@/components/shared/markdown";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Textarea} from "@/components/ui/textarea";
+import {cn} from "@/lib/utils";
+import type {Account, DrivableAgent, DrivenEvent, DrivenSession} from 
"@/types";
+
+/** One tool the agent reached for, and what came back. */
+interface ToolBlock {
+  kind: "tool";
+  seq: number;
+  name: string;
+  input: string;
+  result?: string;
+}
+
+type Block = {kind: "text" | "thinking"; seq: number; text: string} | 
ToolBlock;
+
+/** One exchange: what was asked, and everything said before the next 
question. */
+interface Turn {
+  seq: number;
+  prompt: string;
+  blocks: Block[];
+  error: string;
+  usage?: DrivenEvent;
+  done: boolean;
+}
+
+/**
+ * Flattens the event feed into exchanges. Consecutive pieces of prose are 
joined
+ * so a streamed answer reads as one paragraph rather than as the fragments the
+ * agent happened to send it in.
+ */
+function toTurns(events: DrivenEvent[]): Turn[] {
+  const turns: Turn[] = [];
+  let turn: Turn | null = null;
+
+  const append = (kind: "text" | "thinking", event: DrivenEvent) => {
+    if (!turn || !event.text) {
+      return;
+    }
+    const last = turn.blocks[turn.blocks.length - 1];
+    if (last && last.kind === kind) {
+      last.text += event.text;
+      return;
+    }
+    turn.blocks.push({kind: kind, seq: event.seq, text: event.text});
+  };
+
+  for (const event of events) {
+    switch (event.type) {
+    case "prompt":
+      turn = {seq: event.seq, prompt: event.text ?? "", blocks: [], error: "", 
done: false};
+      turns.push(turn);
+      break;
+    case "text":
+      append("text", event);
+      break;
+    case "thinking":
+      append("thinking", event);
+      break;
+    case "toolUse":
+      turn?.blocks.push({
+        kind: "tool",
+        seq: event.seq,
+        name: event.toolName ?? "",
+        input: event.toolInput ?? "",
+      });
+      break;
+    case "toolResult": {
+      const tool = [...(turn?.blocks ?? [])].reverse().find(block => 
block.kind === "tool") as ToolBlock | undefined;
+      if (tool) {
+        tool.result = (tool.result ?? "") + (event.text ?? "");
+      }
+      break;
+    }
+    case "usage":
+      if (turn && (event.inputTokens || event.outputTokens || event.costUsd)) {
+        turn.usage = event;
+      }
+      break;
+    case "error":
+      if (turn) {
+        turn.error = event.text ?? "";
+      }
+      break;
+    case "done":
+      if (turn) {
+        turn.done = true;
+      }
+      break;
+    }
+  }
+  return turns;
+}
+
+function ToolRow({block}: {block: ToolBlock}) {
+  return (
+    <div className="bg-muted/40 rounded-md border text-xs">
+      <div className="flex items-center gap-2 border-b px-2.5 py-1.5">
+        <Wrench className="text-muted-foreground size-3.5 shrink-0" />
+        <span className="font-medium">{block.name}</span>
+      </div>
+      <pre className="max-h-40 overflow-auto px-2.5 py-1.5 font-mono 
text-[11px] whitespace-pre-wrap">{block.input}</pre>
+      {block.result ? (
+        <pre className="text-muted-foreground max-h-40 overflow-auto border-t 
px-2.5 py-1.5 font-mono text-[11px] whitespace-pre-wrap">
+          {block.result}
+        </pre>
+      ) : null}
+    </div>
+  );
+}
+
+function TurnView({turn}: {turn: Turn}) {
+  return (
+    <div className="flex flex-col gap-3">
+      <div className="flex justify-end">
+        <div className="bg-primary/10 border-primary/20 max-w-[85%] rounded-lg 
border px-3 py-2 text-sm whitespace-pre-wrap">
+          {turn.prompt}
+        </div>
+      </div>
+
+      {turn.blocks.map(block => {
+        if (block.kind === "tool") {
+          return <ToolRow key={block.seq} block={block} />;
+        }
+        if (block.kind === "thinking") {
+          return (
+            <Fold key={block.seq} title={i18next.t("chat:Thinking")}>
+              <div className="text-muted-foreground text-sm 
whitespace-pre-wrap italic">{block.text}</div>
+            </Fold>
+          );
+        }
+        return (
+          <div key={block.seq} className="text-sm">
+            <Markdown content={block.text} />
+          </div>
+        );
+      })}
+
+      {turn.error ? <MessageAlert variant="destructive" title={turn.error} /> 
: null}
+
+      {turn.usage ? (
+        <div className="text-muted-foreground flex flex-wrap items-center 
gap-2 text-xs">
+          <Badge variant="outline">
+            {i18next.t("chat:{count} tokens in").replace("{count}", 
String(turn.usage.inputTokens ?? 0))}
+          </Badge>
+          <Badge variant="outline">
+            {i18next.t("chat:{count} tokens out").replace("{count}", 
String(turn.usage.outputTokens ?? 0))}
+          </Badge>
+          {turn.usage.costUsd ? <Badge 
variant="outline">${turn.usage.costUsd.toFixed(4)}</Badge> : null}
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+/**
+ * Driving an agent from Gateway. The agent that runs is the real one, with the
+ * configuration and the hooks already written into it, so what it costs and 
what
+ * it was allowed to do are recorded exactly as when somebody types at it.
+ */
+export default function ChatPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const [agents, setAgents] = React.useState<DrivableAgent[]>([]);
+  const [sessions, setSessions] = React.useState<DrivenSession[]>([]);
+  const [activeId, setActiveId] = React.useState("");
+  const [events, setEvents] = React.useState<DrivenEvent[]>([]);
+  const [draft, setDraft] = React.useState("");
+  const [target, setTarget] = React.useState("");
+  const [workDir, setWorkDir] = React.useState("");
+  const [error, setError] = React.useState("");
+  const [loading, setLoading] = React.useState(true);
+  const bottom = React.useRef<HTMLDivElement>(null);
+
+  const active = sessions.find(session => session.id === activeId);
+  const running = active?.state === "running";
+
+  const loadSessions = React.useCallback(() => {
+    return DriveBackend.getDrivenSessions()
+      .then(res => {
+        if (res.status === "ok") {
+          setSessions(res.data ?? []);
+        } else {
+          setError(res.msg || i18next.t("general:Failed to get data"));
+        }
+      })
+      .catch(failure => setError(failure.message || String(failure)));
+  }, []);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      setLoading(false);
+      return;
+    }
+    Promise.all([
+      DriveBackend.getDrivableAgents().then(res => {
+        if (res.status === "ok") {
+          const found = res.data ?? [];
+          setAgents(found);
+          setTarget(previous => previous || (found[0] ? found[0].agentId + 
"\n" + found[0].path : ""));
+        }
+      }),
+      loadSessions(),
+    ]).then(() => setLoading(false));
+  }, [isAdmin, loadSessions]);
+
+  // One stream per session: the server replays what was already said, so the
+  // conversation is read from the feed rather than fetched separately.
+  React.useEffect(() => {
+    if (!activeId) {
+      setEvents([]);
+      return;
+    }
+    setEvents([]);
+    const close = DriveBackend.streamDrivenSession(activeId, {
+      onEvent: event => {
+        setEvents(previous => (previous.some(seen => seen.seq === event.seq) ? 
previous : [...previous, event]));
+        if (event.type === "done") {
+          loadSessions();
+        }
+      },
+    });
+    return close;
+  }, [activeId, loadSessions]);
+
+  React.useEffect(() => {
+    bottom.current?.scrollIntoView({behavior: "smooth"});
+  }, [events.length]);
+
+  const open = () => {
+    const [agentId, path] = target.split("\n");
+    const installation = agents.find(candidate => candidate.agentId === 
agentId && candidate.path === path);
+    if (!installation) {
+      return;
+    }
+    DriveBackend.openDrivenSession(
+      {agentId: installation.agentId, path: installation.path, owner: 
installation.owner},
+      workDir.trim(),
+    )
+      .then(res => {
+        if (res.status === "ok" && res.data) {
+          setSessions(previous => [res.data as DrivenSession, ...previous]);
+          setActiveId((res.data as DrivenSession).id);
+          setError("");
+        } else {
+          setError(res.msg || i18next.t("general:Failed to save"));
+        }
+      })
+      .catch(failure => setError(failure.message || String(failure)));
+  };
+
+  const send = () => {
+    const prompt = draft.trim();
+    if (!prompt || !activeId || running) {
+      return;
+    }
+    setDraft("");
+    DriveBackend.sendDrivenSession(activeId, prompt)
+      .then(res => {
+        if (res.status === "ok") {
+          loadSessions();
+        } else {
+          setError(res.msg || i18next.t("general:Failed to save"));
+        }
+      })
+      .catch(failure => setError(failure.message || String(failure)));
+  };
+
+  const close = (id: string) => {
+    DriveBackend.closeDrivenSession(id).then(() => {
+      setSessions(previous => previous.filter(session => session.id !== id));
+      setActiveId(previous => (previous === id ? "" : previous));
+    });
+  };
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  const turns = toTurns(events);
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("chat:Chat")}
+        description={i18next.t("chat:Ask an agent on this machine something, 
and watch it work")}
+      />
+
+      {error ? <MessageAlert variant="destructive" title={error} /> : null}
+
+      <div className="grid gap-4 lg:grid-cols-[300px_1fr]">
+        <div className="flex flex-col gap-3">
+          <div className="flex flex-col gap-2 rounded-lg border p-3">
+            <SimpleSelect
+              value={target}
+              onChange={setTarget}
+              size="sm"
+              placeholder={i18next.t("chat:Pick an agent")}
+              options={agents.map(candidate => ({
+                value: candidate.agentId + "\n" + candidate.path,
+                text: candidate.name,
+                label: (
+                  <span className="flex items-center gap-2">
+                    <AgentIcon agent={candidate.name} size={16} />
+                    <span className="truncate">{candidate.name}</span>
+                  </span>
+                ),
+              }))}
+            />
+            <Input
+              value={workDir}
+              onChange={event => setWorkDir(event.target.value)}
+              placeholder={i18next.t("chat:Working directory")}
+              className="h-8 text-xs"
+            />
+            <Button size="sm" onClick={open} disabled={!target}>
+              <Plus className="size-4" />
+              {i18next.t("chat:New session")}
+            </Button>
+          </div>
+
+          <div className="flex flex-col gap-1">
+            {sessions.map(session => (
+              <button
+                key={session.id}
+                type="button"
+                onClick={() => setActiveId(session.id)}
+                className={cn(
+                  "group hover:bg-muted/60 flex items-center gap-2 rounded-md 
border border-transparent px-2 py-1.5 text-left text-sm",
+                  session.id === activeId && "bg-muted border-border",
+                )}
+              >
+                <AgentIcon agent={session.agentId} size={16} />
+                <span className="min-w-0 flex-1 truncate">
+                  {session.title || i18next.t("chat:New session")}
+                  {session.state === "running" ? <span 
className="text-muted-foreground"> ...</span> : null}
+                </span>
+                <Trash2
+                  className="text-muted-foreground hover:text-destructive 
size-3.5 shrink-0 opacity-0 group-hover:opacity-100"
+                  onClick={event => {
+                    event.stopPropagation();
+                    close(session.id);
+                  }}
+                />
+              </button>
+            ))}
+          </div>
+        </div>
+
+        <div className="flex min-h-[60vh] min-w-0 flex-col rounded-lg border">
+          {active ? (
+            <>
+              <div className="text-muted-foreground flex items-center gap-2 
border-b px-3 py-2 text-xs">
+                <AgentIcon agent={active.agentId} size={14} />
+                <span className="shrink-0 font-medium">{active.agentId}</span>
+                <span className="min-w-0 flex-1 truncate font-mono" 
title={active.workDir}>
+                  {active.workDir || "~"}
+                </span>
+                {active.resumable ? null : <Badge 
variant="outline">{i18next.t("chat:Each message stands alone")}</Badge>}
+              </div>
+
+              <div className="flex-1 overflow-y-auto px-3 py-4">
+                {turns.length === 0 ? (
+                  <EmptyState icon={MessageSquare} 
title={i18next.t("chat:Nothing asked yet")} />
+                ) : (
+                  <div className="flex flex-col gap-6">
+                    {turns.map(turn => (
+                      <TurnView key={turn.seq} turn={turn} />
+                    ))}
+                  </div>
+                )}
+                <div ref={bottom} />
+              </div>
+
+              <div className="flex items-end gap-2 border-t p-2">
+                <Textarea
+                  value={draft}
+                  onChange={event => setDraft(event.target.value)}
+                  onKeyDown={event => {
+                    if (event.key === "Enter" && !event.shiftKey) {
+                      event.preventDefault();
+                      send();
+                    }
+                  }}
+                  placeholder={i18next.t("chat:Ask the agent something")}
+                  className="max-h-40 min-h-10 resize-none"
+                />
+                {running ? (
+                  <Button size="icon" variant="outline" onClick={() => 
DriveBackend.interruptDrivenSession(activeId)}>
+                    <Square className="size-4" />
+                  </Button>
+                ) : (
+                  <Button size="icon" onClick={send} disabled={!draft.trim()}>
+                    <ArrowUp className="size-4" />
+                  </Button>
+                )}
+              </div>
+            </>
+          ) : (
+            <EmptyState
+              icon={MessageSquare}
+              title={loading ? i18next.t("general:Loading") : 
i18next.t("chat:No session is open")}
+              description={
+                agents.length === 0 && !loading ? i18next.t("chat:No agent on 
this machine can be driven") : undefined
+              }
+            />
+          )}
+        </div>
+      </div>
+    </PageContainer>
+  );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 1fde7d3..b5d138b 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -522,6 +522,52 @@ export interface AgentSession {
   historical?: boolean;
 }
 
+/** One installation Gateway can hand a prompt to. */
+export interface DrivableAgent {
+  agentId: string;
+  name: string;
+  path: string;
+  owner: string;
+  /** False for an agent that carries nothing from one turn to the next. */
+  resumable: boolean;
+}
+
+/** One conversation Gateway is driving on somebody's behalf. */
+export interface DrivenSession {
+  id: string;
+  agentId: string;
+  agentPath: string;
+  owner: string;
+  workDir: string;
+  model: string;
+  source: string;
+  nativeId: string;
+  resumable: boolean;
+  title: string;
+  state: "idle" | "running" | "failed";
+  turns: number;
+  createdTime: string;
+  updatedTime: string;
+  lastError?: string;
+}
+
+/** One piece of what a driven agent said, in the shape every agent is 
flattened into. */
+export interface DrivenEvent {
+  type: "prompt" | "text" | "thinking" | "toolUse" | "toolResult" | "usage" | 
"done" | "error";
+  seq: number;
+  createdTime: string;
+  text?: string;
+  toolName?: string;
+  toolUseId?: string;
+  toolInput?: string;
+  model?: string;
+  nativeId?: string;
+  inputTokens?: number;
+  outputTokens?: number;
+  costUsd?: number;
+  durationMs?: number;
+}
+
 /** One part of a transcript message: prose, thinking, a tool call or its 
result. */
 export interface AgentMessageBlock {
   kind: "text" | "thinking" | "toolUse" | "toolResult" | "image";


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to