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


The following commit(s) were added to refs/heads/master by this push:
     new ffce02a  feat(agent): edit and sync each agent's instruction file
ffce02a is described below

commit ffce02acec8aa3c2d0463ce0754341b6f102ef2b
Author: Yang Luo <[email protected]>
AuthorDate: Wed Aug 26 01:45:37 2026 +0800

    feat(agent): edit and sync each agent's instruction file
---
 README.md                                         |   4 +-
 README_zh.md                                      |   4 +-
 agentconfig/agentconfig.go                        |  88 ++++++++++--
 agentconfig/layout.go                             |  41 +++++-
 agentconfig/prompt.go                             | 148 +++++++++++++++++++
 agentconfig/trash.go                              |  42 ++++--
 controllers/agent_config.go                       |  46 ++++--
 routers/router.go                                 |   1 +
 web/src/backend/AgentConfigBackend.ts             |   9 ++
 web/src/components/agent-config/copy-dialog.tsx   |   4 +-
 web/src/components/agent-config/prompt-dialog.tsx | 166 ++++++++++++++++++++++
 web/src/components/agent-config/trash-dialog.tsx  |   8 +-
 web/src/lib/agent-configs.ts                      |  52 +++++--
 web/src/locales/en/data.json                      |  18 ++-
 web/src/locales/zh/data.json                      |  18 ++-
 web/src/nav.ts                                    |   2 +-
 web/src/pages/AgentConfigsPage.tsx                | 103 +++++++++++---
 web/src/types.ts                                  |  23 ++-
 18 files changed, 681 insertions(+), 96 deletions(-)

diff --git a/README.md b/README.md
index de33a72..6f26403 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ Gateway installed some other way, or in a directory it cannot 
write to, says so
 | Page | What you get | What it needs |
 | --- | --- | --- |
 | **Agents** | Every AI coding agent installed on this machine — Claude Code, 
Codex CLI, Cursor and more. Click **Patch** on one and its activity streams 
into the page live. | Nothing |
-| **Skills & MCP** | Every skill and MCP server of every agent in one table. 
Add an MCP server to one agent or to several at once, open one, delete it, or 
copy it into another agent. | Nothing |
+| **Skills, MCP & Prompts** | Every skill, MCP server and instruction file of 
every agent in one table. Add an MCP server to one agent or to several at once, 
edit the instructions an agent reads before every session, open one, delete it, 
or copy it into another agent. | Nothing |
 | **Providers** | One endpoint in front of your model vendors. Gateway holds 
the API key, so the agents never have it — or forwards the agent's own sign-in 
and holds nothing. | A vendor API key, or nothing at all |
 | **LLM Records** | Every request an agent relayed: the full system prompt, 
every message and tool call, the schema of every tool the model was offered, 
plus tokens and cost. | A provider, and `llmRecordMode` — see [Recording 
prompts](#recording-prompts) |
 | **Advanced → Sites** | The reverse-proxy WAF: per-site routing, rules, 
certificates and analytics. | Turning the proxy on — see [Turning the WAF proxy 
on](#turning-the-waf-proxy-on) |
@@ -140,7 +140,7 @@ To serve other machines anyway, set `httpaddr = 0.0.0.0` in 
`conf/app.conf`, and
 
 ### Running in Docker or Podman
 
-**A container cannot see the agents on your machine.** Agents are discovered 
by reading the home directories and install paths of the machine Gateway runs 
on, and inside a container that is the container's own filesystem. **Agents**, 
**Skills & MCP** and agent monitoring therefore stay empty there, and the pages 
say so rather than pretending nothing is installed. Everything that does not 
depend on the host works normally: **Providers**, **LLM Records** and the 
reverse-proxy WAF.
+**A container cannot see the agents on your machine.** Agents are discovered 
by reading the home directories and install paths of the machine Gateway runs 
on, and inside a container that is the container's own filesystem. **Agents**, 
**Skills, MCP & Prompts** and agent monitoring therefore stay empty there, and 
the pages say so rather than pretending nothing is installed. Everything that 
does not depend on the host works normally: **Providers**, **LLM Records** and 
the reverse-proxy WAF.
 
 So run the one-command install above on the machine whose agents you want to 
watch, and use a container when Gateway is only a model endpoint or a reverse 
proxy for other machines.
 
diff --git a/README_zh.md b/README_zh.md
index 1bfe0be..8b49302 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -84,7 +84,7 @@ Gateway 会在自己的窗口里打开 —— 不用登录:它只服务本机
 | 页面 | 你能得到什么 | 需要什么 |
 | --- | --- | --- |
 | **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor 等等。点其中一个的 
**Patch**,它的活动就会实时流进页面。 | 无 |
-| **Skills & MCP** | 所有 Agent 的所有技能和 MCP 服务器汇总在一张表里。可以给一个或多个 Agent 添加 MCP 
服务器,也可以打开、删除,或复制到另一个 Agent。 | 无 |
+| **Skills, MCP & Prompts** | 所有 Agent 的所有技能、MCP 服务器和提示词文件汇总在一张表里。可以给一个或多个 
Agent 添加 MCP 服务器,编辑某个 Agent 每次会话前读到的提示词,也可以打开、删除,或复制到另一个 Agent。 | 无 |
 | **Providers** | 挡在模型厂商前面的统一入口。API Key 由 Gateway 持有,Agent 拿不到;也可以转发 Agent 
自己的登录,什么都不持有。 | 一个厂商的 API Key,或者什么都不用 |
 | **LLM Records** | Agent 转发的每一次请求:完整的 system prompt、每一条消息和工具调用、模型可用的每个工具的 
schema,以及 token 数和费用。 | 一个 Provider,以及 `llmRecordMode` —— 见[记录提示词](#记录提示词) |
 | **Advanced → Sites** | 反向代理 WAF:按站点的路由、规则、证书和统计。 | 打开代理 —— 见[开启 WAF 
反向代理](#开启-waf-反向代理) |
@@ -138,7 +138,7 @@ Gateway 默认只监听 `127.0.0.1`,因为有两样东西对能连上这个端
 
 ### 在 Docker 或 Podman 里运行
 
-**容器看不到你机器上的 Agent。** Agent 是靠读取 Gateway 
所在机器的用户主目录和安装路径发现的,而在容器里那是容器自己的文件系统。所以 **Agents**、**Skills & MCP** 和 Agent 
监控在容器里一直是空的,页面会直接说明这一点,而不是让人以为什么都没装。不依赖宿主机的部分照常可用:**Providers**、**LLM Records** 
和 WAF 反向代理。
+**容器看不到你机器上的 Agent。** Agent 是靠读取 Gateway 
所在机器的用户主目录和安装路径发现的,而在容器里那是容器自己的文件系统。所以 **Agents**、**Skills, MCP & Prompts** 和 
Agent 监控在容器里一直是空的,页面会直接说明这一点,而不是让人以为什么都没装。不依赖宿主机的部分照常可用:**Providers**、**LLM 
Records** 和 WAF 反向代理。
 
 因此,想监控哪台机器上的 Agent,就在那台机器上跑上面的一键安装;只把 Gateway 当作模型入口或别的机器的反向代理时,才用容器部署。
 
diff --git a/agentconfig/agentconfig.go b/agentconfig/agentconfig.go
index ec09bc2..3072451 100644
--- a/agentconfig/agentconfig.go
+++ b/agentconfig/agentconfig.go
@@ -12,11 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-// Package agentconfig reads and edits the skills and MCP servers that AI 
coding
-// agents keep in their own configuration files, so the agents installed on one
-// host can be compared and their configuration moved between them.
+// Package agentconfig reads and edits the skills, MCP servers and instruction
+// files that AI coding agents keep in their own configuration files, so the
+// agents installed on one host can be compared and their configuration moved
+// between them.
 //
-// Every agent stores the same two things in a different place and a different
+// Every agent stores the same three things in a different place and a 
different
 // format. layout.go holds that per-agent knowledge, and nothing else does.
 package agentconfig
 
@@ -27,27 +28,35 @@ import (
        "strings"
 )
 
-// The two kinds of configuration Gateway manages.
+// The kinds of configuration Gateway manages.
 const (
        KindSkill = "skill"
        KindMcp   = "mcp"
+       // KindPrompt is the Markdown file an agent reads before every session.
+       KindPrompt = "prompt"
 )
 
 // ErrUnsupported reports a kind an agent has no known location for, which is
 // different from an agent that has one and it is empty.
 var ErrUnsupported = errors.New("Gateway does not know where this agent keeps 
that configuration")
 
-// Item is one skill or MCP server as it exists in an agent's own 
configuration.
+// Item is one skill, MCP server or instruction file as it exists in an agent's
+// own configuration.
 type Item struct {
        AgentId string `json:"agentId"`
        Owner   string `json:"owner"`
        Kind    string `json:"kind"`
        Name    string `json:"name"`
 
+       // Shared is the name two agents' copies of this item are matched by, 
when
+       // that is not the name itself. Every agent's instruction file holds 
the same
+       // thing under a different file name.
+       Shared string `json:"shared,omitempty"`
+
        Description string `json:"description,omitempty"`
 
-       // Path is what an edit would change: the skill's own directory, or the
-       // config file the MCP server is one entry of.
+       // Path is what an edit would change: the skill's own directory, the 
config
+       // file the MCP server is one entry of, or the instruction file itself.
        Path string `json:"path"`
 
        // Transport, Command and Url summarize an MCP server in a list.
@@ -82,6 +91,10 @@ type Item struct {
        // ReadOnly explains why Gateway will not delete this item. Empty for 
the
        // items it may.
        ReadOnly string `json:"readOnly,omitempty"`
+       // Missing marks an item listed because the agent would read it, not 
because
+       // it is there. An instruction file nobody has written is still the 
answer to
+       // what that agent is told.
+       Missing bool `json:"missing,omitempty"`
 }
 
 // Inventory is everything Gateway can read for one installation. A location it
@@ -99,9 +112,11 @@ type Inventory struct {
        SkillsDir  string   `json:"skillsDir,omitempty"`
        SkillsDirs []string `json:"skillsDirs,omitempty"`
        McpFile    string   `json:"mcpFile,omitempty"`
+       PromptFile string   `json:"promptFile,omitempty"`
 
        SkillsSupported bool `json:"skillsSupported"`
        McpSupported    bool `json:"mcpSupported"`
+       PromptSupported bool `json:"promptSupported"`
        // McpWritable is false for a config Gateway can read but must not 
rewrite,
        // because writing it back would lose something it cannot represent.
        McpWritable bool   `json:"mcpWritable"`
@@ -109,13 +124,15 @@ type Inventory struct {
 
        Skills     []*Item  `json:"skills"`
        McpServers []*Item  `json:"mcpServers"`
+       Prompts    []*Item  `json:"prompts"`
        Errors     []string `json:"errors,omitempty"`
 }
 
 // Detail is one item's full definition, for the viewer.
 type Detail struct {
        Item *Item `json:"item"`
-       // Content is the SKILL.md of a skill, or the JSON of one MCP server 
entry.
+       // Content is the SKILL.md of a skill, the JSON of one MCP server 
entry, or
+       // the instruction file itself.
        Content string `json:"content"`
        // Files lists the other files a skill directory carries.
        Files []string `json:"files,omitempty"`
@@ -137,6 +154,7 @@ func Read(agentId string, owner string) *Inventory {
                Owner:      owner,
                Skills:     []*Item{},
                McpServers: []*Item{},
+               Prompts:    []*Item{},
        }
 
        found, ok := layoutOf(agentId)
@@ -145,6 +163,7 @@ func Read(agentId string, owner string) *Inventory {
        }
        inventory.SkillsSupported = found.skills != nil
        inventory.McpSupported = found.mcp != nil
+       inventory.PromptSupported = found.prompt != nil
        if found.mcp != nil {
                inventory.McpWritable = found.mcp.readOnly == ""
                inventory.McpReadOnly = found.mcp.readOnly
@@ -174,6 +193,11 @@ func Read(agentId string, owner string) *Inventory {
                }
                inventory.McpServers = mcpItems(agentId, owner, 
inventory.McpFile, entries)
        }
+
+       if found.prompt != nil {
+               inventory.PromptFile = found.prompt.path(home)
+               inventory.Prompts = []*Item{readPrompt(agentId, owner, 
inventory.PromptFile)}
+       }
        return inventory
 }
 
@@ -183,11 +207,14 @@ func ReadDetail(agentId string, owner string, kind 
string, name string) (*Detail
        if err != nil {
                return nil, err
        }
-       if kind == KindSkill {
+       if kind == KindSkill || kind == KindPrompt {
                item, err := findItem(agentId, owner, kind, name)
                if err != nil {
                        return nil, err
                }
+               if kind == KindPrompt {
+                       return promptDetail(item)
+               }
                return skillDetail(item)
        }
 
@@ -222,6 +249,9 @@ func Delete(agentId string, owner string, kind string, name 
string) error {
        if kind == KindSkill {
                return trashSkill(home, item)
        }
+       if kind == KindPrompt {
+               return trashPath(home, item)
+       }
        if found.mcp.readOnly != "" {
                return fmt.Errorf("%s: %s", agentId, found.mcp.readOnly)
        }
@@ -298,12 +328,17 @@ func plan(request CopyRequest, sources 
map[string]*source) []*PlanItem {
                for _, name := range request.Names {
                        item := &PlanItem{AgentId: agentId, Name: name}
                        from := sources[name]
-                       target := existing[targetName(request.Kind, name)]
+                       var target *Item
+                       if from != nil {
+                               target = existing[sharedKey(from.item)]
+                       }
                        switch {
                        case err != nil:
                                item.Action, item.Reason = ActionSkip, 
err.Error()
                        case from == nil:
                                item.Action, item.Reason = ActionSkip, "not 
found in the source agent"
+                       case from.item.Missing:
+                               item.Action, item.Reason = ActionSkip, "there 
is nothing there to copy"
                        case from.item.Managed:
                                item.Action, item.Reason = ActionSkip, "written 
by Gateway, not migrated"
                        case target == nil:
@@ -340,6 +375,16 @@ func replaces(from *Item, target *Item) string {
        return "replaces an older version"
 }
 
+// sharedKey is what one agent's copy of an item is matched to another's. Two
+// agents' instruction files are the same item under two file names, so they 
say
+// so rather than being matched by name.
+func sharedKey(item *Item) string {
+       if item.Shared != "" {
+               return item.Shared
+       }
+       return targetName(item.Kind, item.Name)
+}
+
 // targetName is the name an item takes at the target. A skill from a plugin or
 // from a group inside the skills directory is qualified, and it lands in the
 // target's skills directory under the last part of that name.
@@ -392,6 +437,9 @@ func writeItem(request CopyRequest, from *source, agentId 
string) (string, error
                recordSkillOrigin(home, path, request.From, from.item.Name, 
from.item.Path)
                return path, nil
        }
+       if request.Kind == KindPrompt {
+               return copyPrompt(home, agentId, request.Owner, 
found.prompt.path(home), from.item)
+       }
        if found.mcp.readOnly != "" {
                return "", errors.New(found.mcp.readOnly)
        }
@@ -408,7 +456,7 @@ func readSources(request CopyRequest) (map[string]*source, 
error) {
                return nil, errors.New("no target agent was selected")
        case len(request.Names) == 0:
                return nil, errors.New("nothing was selected to copy")
-       case request.Kind != KindSkill && request.Kind != KindMcp:
+       case request.Kind != KindSkill && request.Kind != KindMcp && 
request.Kind != KindPrompt:
                return nil, fmt.Errorf("unknown configuration kind: %s", 
request.Kind)
        }
        for _, agentId := range request.To {
@@ -456,14 +504,22 @@ func targetItems(agentId string, owner string, kind 
string) (map[string]*Item, e
 
        items := map[string]*Item{}
        for _, item := range itemsOf(inventory, kind) {
-               items[targetName(kind, item.Name)] = item
+               // A listed instruction file the target has not written is 
nothing to
+               // replace, so a copy into it creates rather than overwrites.
+               if item.Missing {
+                       continue
+               }
+               items[sharedKey(item)] = item
        }
        return items, nil
 }
 
 func itemsOf(inventory *Inventory, kind string) []*Item {
-       if kind == KindSkill {
+       switch kind {
+       case KindSkill:
                return inventory.Skills
+       case KindPrompt:
+               return inventory.Prompts
        }
        return inventory.McpServers
 }
@@ -475,7 +531,9 @@ func resolve(agentId string, owner string, kind string) 
(layout, string, error)
        if !ok {
                return layout{}, "", fmt.Errorf("unknown agent: %s", agentId)
        }
-       if (kind == KindSkill && found.skills == nil) || (kind == KindMcp && 
found.mcp == nil) {
+       if (kind == KindSkill && found.skills == nil) ||
+               (kind == KindMcp && found.mcp == nil) ||
+               (kind == KindPrompt && found.prompt == nil) {
                return layout{}, "", fmt.Errorf("%s: %w", agentId, 
ErrUnsupported)
        }
        home, err := homeOf(owner)
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 0ad1e30..5ba4d8b 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -28,12 +28,14 @@ import (
 // migrated or deleted from here: turning monitoring off is what removes it.
 const ManagedEntryName = "casbin-gateway-agent-monitor"
 
-// layout is where one agent keeps its skills and MCP servers. A nil half means
-// the agent has no such location that Gateway knows about, which the UI shows
-// as unsupported rather than as empty.
+// layout is where one agent keeps its skills, its MCP servers and the
+// instructions it reads at the start of every session. A nil part means the
+// agent has no such location that Gateway knows about, which the UI shows as
+// unsupported rather than as empty.
 type layout struct {
        skills *skillLayout
        mcp    *mcpLayout
+       prompt *promptLayout
 }
 
 // Where a skill came from. A plugin's skills are read like any other but are
@@ -121,6 +123,23 @@ func (l *mcpLayout) path(home string) string {
        return l.file(home)
 }
 
+// promptLayout is the one Markdown file an agent reads before every session.
+// The agents spell it differently — CLAUDE.md, AGENTS.md, global_rules.md — 
and
+// hold the same thing in it.
+type promptLayout struct {
+       segments []string
+}
+
+func (l *promptLayout) path(home string) string {
+       return filepath.Join(append([]string{home}, l.segments...)...)
+}
+
+// promptFile names one agent's instruction file, last segment first so the 
file
+// name reads before the directory it sits in.
+func promptFile(name string, dir ...string) *promptLayout {
+       return &promptLayout{segments: append(dir, name)}
+}
+
 // mcpStore is the file format half: JSON objects for most agents, TOML tables
 // for Codex. Entries are keyed by server name and hold that server's own
 // fields, in whatever spelling the file used.
@@ -140,7 +159,8 @@ var layouts = map[string]layout{
                        pluginSkills(".claude", "plugins"),
                        projectSkills(jsonProjects(".claude.json"), ".claude", 
"skills"),
                }},
-               mcp: &mcpLayout{file: under(".claude.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+               mcp:    &mcpLayout{file: under(".claude.json"), store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
+               prompt: promptFile("CLAUDE.md", ".claude"),
        },
        "claude-desktop": {
                mcp: &mcpLayout{file: claudeDesktopConfig, store: 
&jsonStore{paths: [][]string{{"mcpServers"}}}},
@@ -156,6 +176,7 @@ var layouts = map[string]layout{
                        file:  under(".codeium", "windsurf", "mcp_config.json"),
                        store: &jsonStore{paths: [][]string{{"mcpServers"}}},
                },
+               prompt: promptFile("global_rules.md", ".codeium", "windsurf", 
"memories"),
        },
        // dsh reads the shared ~/.agents skills too, but writes go to its own
        // directory, which is the first user source.
@@ -178,6 +199,7 @@ var layouts = map[string]layout{
                        // all three; the first is where a new entry goes.
                        store: &jsonStore{paths: [][]string{{"mcp", "servers"}, 
{"mcp", "mcpServers"}, {"mcpServers"}}},
                },
+               prompt: promptFile("AGENTS.md", ".openclaw"),
        },
        "opencode":         opencodeLayout,
        "opencode-desktop": opencodeLayout,
@@ -188,7 +210,8 @@ var codexLayout = layout{
                userSkills(".codex", "skills"),
                pluginSkills(".codex", "plugins"),
        }},
-       mcp: &mcpLayout{file: under(".codex", "config.toml"), store: 
&tomlStore{table: "mcp_servers"}},
+       mcp:    &mcpLayout{file: under(".codex", "config.toml"), store: 
&tomlStore{table: "mcp_servers"}},
+       prompt: promptFile("AGENTS.md", ".codex"),
 }
 
 // The opencode CLI and its desktop app read one ~/.config/opencode, on every
@@ -198,7 +221,8 @@ var opencodeLayout = layout{
                userSkills(".config", "opencode", "skill"),
                userSkills(".config", "opencode", "skills"),
        }},
-       mcp: &mcpLayout{file: opencodeConfig, store: newOpencodeStore()},
+       mcp:    &mcpLayout{file: opencodeConfig, store: newOpencodeStore()},
+       prompt: promptFile("AGENTS.md", ".config", "opencode"),
 }
 
 var cursorLayout = layout{
@@ -299,7 +323,10 @@ func Configured(agentId string, owner string) bool {
                        }
                }
        }
-       return found.mcp != nil && exists(found.mcp.path(home))
+       if found.mcp != nil && exists(found.mcp.path(home)) {
+               return true
+       }
+       return found.prompt != nil && exists(found.prompt.path(home))
 }
 
 func exists(path string) bool {
diff --git a/agentconfig/prompt.go b/agentconfig/prompt.go
new file mode 100644
index 0000000..81367c5
--- /dev/null
+++ b/agentconfig/prompt.go
@@ -0,0 +1,148 @@
+// 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 agentconfig
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+       "strings"
+)
+
+const (
+       // maxPromptBytes bounds what the editor loads and what a save accepts.
+       maxPromptBytes = 256 * 1024
+       // maxPromptSummary bounds the first line shown beside the file name.
+       maxPromptSummary = 160
+       // promptShared matches one agent's instruction file to every other's. 
They
+       // hold the same thing under a different name, which is the whole 
reason for
+       // listing them side by side.
+       promptShared = "instructions"
+       // promptMissing explains a file that is not there, and doubles as the
+       // reason there is nothing to delete.
+       promptMissing = "this agent has no instructions yet"
+)
+
+// readPrompt describes one agent's instruction file. A file nobody has written
+// yet is listed as missing rather than left out: what the agent is told is
+// "nothing", and that is the answer the page exists to give.
+func readPrompt(agentId string, owner string, path string) *Item {
+       item := &Item{
+               AgentId: agentId,
+               Owner:   owner,
+               Kind:    KindPrompt,
+               Name:    filepath.Base(path),
+               Shared:  promptShared,
+               Path:    path,
+               Scope:   ScopeUser,
+       }
+
+       info, err := os.Stat(path)
+       if err != nil || info.IsDir() {
+               item.Missing = true
+               item.ReadOnly = promptMissing
+               return item
+       }
+
+       stat := measure(path)
+       item.Files, item.Bytes, item.Digest, item.Modified = stat.files, 
stat.bytes, stat.digest, stat.modified
+       if raw, err := readPromptContent(path); err == nil {
+               item.Description = promptSummary(string(raw))
+       }
+       return item
+}
+
+func promptDetail(item *Item) (*Detail, error) {
+       if item.Missing {
+               return &Detail{Item: item, Content: ""}, nil
+       }
+
+       raw, err := readPromptContent(item.Path)
+       if err != nil {
+               return nil, err
+       }
+       return &Detail{Item: item, Content: string(raw)}, nil
+}
+
+// SavePrompt replaces one agent's instruction file, creating it when it is not
+// there yet. There is one such file per agent, so it is named by the agent
+// rather than by itself.
+func SavePrompt(agentId string, owner string, content string) (*Item, error) {
+       found, home, err := resolve(agentId, owner, KindPrompt)
+       if err != nil {
+               return nil, err
+       }
+       if len(content) > maxPromptBytes {
+               return nil, fmt.Errorf("these instructions are larger than the 
%d bytes Gateway edits", maxPromptBytes)
+       }
+
+       path := found.prompt.path(home)
+       _, mode, err := readFile(path)
+       if err != nil {
+               return nil, err
+       }
+       if err := writeFile(path, []byte(content), mode); err != nil {
+               return nil, err
+       }
+       return readPrompt(agentId, owner, path), nil
+}
+
+// copyPrompt writes one agent's instructions into another agent's own file,
+// under whatever name that agent reads. What it replaces goes to the recycle
+// bin first: an instruction file is written by hand and there is no other 
copy.
+func copyPrompt(home string, agentId string, owner string, path string, from 
*Item) (string, error) {
+       raw, err := readPromptContent(from.Path)
+       if err != nil {
+               return "", err
+       }
+
+       _, mode, err := readFile(path)
+       if err != nil {
+               return "", err
+       }
+       if existing := readPrompt(agentId, owner, path); !existing.Missing {
+               if err := trashPath(home, existing); err != nil {
+                       return "", err
+               }
+       }
+       return path, writeFile(path, raw, mode)
+}
+
+func readPromptContent(path string) ([]byte, error) {
+       info, err := os.Stat(path)
+       if err != nil {
+               return nil, err
+       }
+       if info.Size() > maxPromptBytes {
+               return nil, fmt.Errorf("%s is larger than the %d bytes Gateway 
edits", path, maxPromptBytes)
+       }
+       return os.ReadFile(path)
+}
+
+// promptSummary is the first line of the file with any heading marks taken 
off,
+// so a row says what these instructions are about.
+func promptSummary(content string) string {
+       for _, line := range strings.Split(content, "\n") {
+               line = 
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
+               if line == "" {
+                       continue
+               }
+               if letters := []rune(line); len(letters) > maxPromptSummary {
+                       line = string(letters[:maxPromptSummary]) + "..."
+               }
+               return line
+       }
+       return ""
+}
diff --git a/agentconfig/trash.go b/agentconfig/trash.go
index 2f2e75e..dd80a62 100644
--- a/agentconfig/trash.go
+++ b/agentconfig/trash.go
@@ -30,7 +30,7 @@ const (
        trashRoot   = ".casbin-gateway"
        trashFolder = "trash"
        // trashRecord names the metadata of one deleted item, and trashPayload 
the
-       // skill folder beside it.
+       // folder or file beside it.
        trashRecord  = "gateway-trash.json"
        trashPayload = "payload"
        // trashKeepDays is how long a deleted item can be restored. After that 
a
@@ -38,8 +38,8 @@ const (
        trashKeepDays = 30
 )
 
-// TrashEntry is one deleted skill or MCP server, kept so a delete can be taken
-// back.
+// TrashEntry is one deleted skill, MCP server or instruction file, kept so a
+// delete can be taken back.
 type TrashEntry struct {
        Id          string `json:"id"`
        AgentId     string `json:"agentId"`
@@ -61,6 +61,28 @@ type TrashEntry struct {
 // trashSkill moves a skill folder out of the agent's reach instead of deleting
 // it, so an accidental delete is one restore away.
 func trashSkill(home string, item *Item) error {
+       if err := trashPath(home, item); err != nil {
+               return err
+       }
+
+       // Nothing is at that path now, so the next skill written there is not 
the
+       // one this record was about.
+       forgetSkillOrigin(home, item.Path)
+       return nil
+}
+
+// trashItem recycles one item by its kind, so replacing a skill keeps its
+// origin bookkeeping in step.
+func trashItem(home string, item *Item) error {
+       if item.Kind == KindSkill {
+               return trashSkill(home, item)
+       }
+       return trashPath(home, item)
+}
+
+// trashPath moves whatever is at the item's path into the recycle bin: a
+// skill folder, or one agent's instruction file.
+func trashPath(home string, item *Item) error {
        entry := newTrashEntry(item)
        dir, err := makeTrashDir(home, entry.Id)
        if err != nil {
@@ -78,10 +100,6 @@ func trashSkill(home string, item *Item) error {
                os.RemoveAll(dir)
                return err
        }
-
-       // Nothing is at that path now, so the next skill written there is not 
the
-       // one this record was about.
-       forgetSkillOrigin(home, item.Path)
        return nil
 }
 
@@ -161,10 +179,10 @@ func RestoreTrash(owner string, id string, replace bool) 
(*TrashEntry, error) {
                return nil, err
        }
 
-       if entry.Kind == KindSkill {
-               err = restoreSkill(home, dir, entry, replace)
-       } else {
+       if entry.Kind == KindMcp {
                err = restoreMcp(owner, entry, replace)
+       } else {
+               err = restorePayload(home, dir, entry, replace)
        }
        if err != nil {
                return nil, err
@@ -175,12 +193,12 @@ func RestoreTrash(owner string, id string, replace bool) 
(*TrashEntry, error) {
        return entry, nil
 }
 
-func restoreSkill(home string, dir string, entry *TrashEntry, replace bool) 
error {
+func restorePayload(home string, dir string, entry *TrashEntry, replace bool) 
error {
        if exists(entry.Path) {
                if !replace {
                        return fmt.Errorf("%s already exists again; restore it 
as a replacement, or move it aside first", entry.Path)
                }
-               if err := trashSkill(home, occupant(entry)); err != nil {
+               if err := trashItem(home, occupant(entry)); err != nil {
                        return err
                }
        }
diff --git a/controllers/agent_config.go b/controllers/agent_config.go
index a7c0563..af6cccd 100644
--- a/controllers/agent_config.go
+++ b/controllers/agent_config.go
@@ -38,8 +38,8 @@ type agentConfigView struct {
        SharedWith []string `json:"sharedWith,omitempty"`
 }
 
-// GetAgentConfigs lists the skills and MCP servers of every agent on this host
-// whose configuration layout Gateway knows.
+// GetAgentConfigs lists the skills, MCP servers and instructions of every 
agent
+// on this host whose configuration layout Gateway knows.
 func (c *ApiController) GetAgentConfigs() {
        if c.RequireAdmin() {
                return
@@ -86,7 +86,7 @@ func (collected *agentConfigs) add(agentId string, owner 
string, name string, pa
        }
 
        inventory := agentconfig.Read(agentId, owner)
-       key := inventory.SkillsDir + "\x00" + inventory.McpFile
+       key := inventory.SkillsDir + "\x00" + inventory.McpFile + "\x00" + 
inventory.PromptFile
        if first, ok := collected.seen[key]; ok {
                if first.AgentId != agentId && !contains(first.SharedWith, 
name) {
                        first.SharedWith = append(first.SharedWith, name)
@@ -113,7 +113,33 @@ func contains(names []string, name string) bool {
        return false
 }
 
-// GetAgentConfigItem returns one skill's manifest or one MCP server's entry.
+// SaveAgentConfigPrompt replaces the instructions one agent reads before every
+// session, writing the file in that agent's own place and under its own name.
+func (c *ApiController) SaveAgentConfigPrompt() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var form struct {
+               AgentId string `json:"agentId"`
+               Owner   string `json:"owner"`
+               Content string `json:"content"`
+       }
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       item, err := agentconfig.SavePrompt(form.AgentId, form.Owner, 
form.Content)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       c.ResponseOk(item)
+}
+
+// GetAgentConfigItem returns one skill's manifest, one MCP server's entry, or
+// one agent's instructions.
 func (c *ApiController) GetAgentConfigItem() {
        if c.RequireAdmin() {
                return
@@ -128,8 +154,8 @@ func (c *ApiController) GetAgentConfigItem() {
        c.ResponseOk(detail)
 }
 
-// DeleteAgentConfigItem removes one skill or MCP server from the agent's own
-// configuration.
+// DeleteAgentConfigItem removes one skill, MCP server or instruction file from
+// the agent's own configuration.
 func (c *ApiController) DeleteAgentConfigItem() {
        if c.RequireAdmin() {
                return
@@ -157,8 +183,8 @@ func (c *ApiController) DeleteAgentConfigItem() {
        c.ResponseOk(form.Name)
 }
 
-// GetAgentConfigTrash lists the skills and MCP servers deleting has removed 
and
-// that can still be put back.
+// GetAgentConfigTrash lists what deleting has removed and can still be put
+// back.
 func (c *ApiController) GetAgentConfigTrash() {
        if c.RequireAdmin() {
                return
@@ -287,8 +313,8 @@ func (c *ApiController) PlanAgentConfigCopy() {
        c.ResponseOk(planned)
 }
 
-// CopyAgentConfig migrates the selected skills or MCP servers into the target
-// agents' own configuration files.
+// CopyAgentConfig migrates the selected skills, MCP servers or instructions
+// into the target agents' own configuration files.
 func (c *ApiController) CopyAgentConfig() {
        if c.RequireAdmin() {
                return
diff --git a/routers/router.go b/routers/router.go
index 7b5e1a8..042d1b7 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -88,6 +88,7 @@ func initAPI() {
        beego.Router("/api/purge-agent-config-trash", 
&controllers.ApiController{}, "POST:PurgeAgentConfigTrash")
        beego.Router("/api/update-agent-config-skill", 
&controllers.ApiController{}, "POST:UpdateAgentConfigSkill")
        beego.Router("/api/add-agent-config-mcp", &controllers.ApiController{}, 
"POST:AddAgentConfigMcp")
+       beego.Router("/api/save-agent-config-prompt", 
&controllers.ApiController{}, "POST:SaveAgentConfigPrompt")
        beego.Router("/api/plan-agent-config-copy", 
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
        beego.Router("/api/plan-agent-provider", &controllers.ApiController{}, 
"POST:PlanAgentProvider")
        beego.Router("/api/apply-agent-provider", &controllers.ApiController{}, 
"POST:ApplyAgentProvider")
diff --git a/web/src/backend/AgentConfigBackend.ts 
b/web/src/backend/AgentConfigBackend.ts
index 6111c66..4dc593a 100644
--- a/web/src/backend/AgentConfigBackend.ts
+++ b/web/src/backend/AgentConfigBackend.ts
@@ -50,6 +50,15 @@ export function addAgentConfigMcp(body: McpRequest) {
   return request<AgentConfigPlanItem[]>("/api/add-agent-config-mcp", "POST", 
body);
 }
 
+/** Replaces the instructions one agent reads before every session. */
+export function saveAgentConfigPrompt(agentId: string, owner: string, content: 
string) {
+  return request<AgentConfigItem>("/api/save-agent-config-prompt", "POST", {
+    agentId: agentId,
+    owner: owner,
+    content: content,
+  });
+}
+
 export function getAgentConfigs(forceRefresh = false) {
   return request<AgentConfigInventory[]>(`/api/get-agent-configs${forceRefresh 
? "?refresh=true" : ""}`);
 }
diff --git a/web/src/components/agent-config/copy-dialog.tsx 
b/web/src/components/agent-config/copy-dialog.tsx
index 58594f8..89d4f8b 100644
--- a/web/src/components/agent-config/copy-dialog.tsx
+++ b/web/src/components/agent-config/copy-dialog.tsx
@@ -176,7 +176,9 @@ export function CopyDialog({
           <DialogTitle>
             {kind === "skill"
               ? counted(names.length, "agentConfig:Copy 1 skill", 
"agentConfig:Copy {count} skills")
-              : counted(names.length, "agentConfig:Copy 1 MCP server", 
"agentConfig:Copy {count} MCP servers")}
+              : kind === "prompt"
+                ? i18next.t("agentConfig:Copy these instructions")
+                : counted(names.length, "agentConfig:Copy 1 MCP server", 
"agentConfig:Copy {count} MCP servers")}
           </DialogTitle>
           <DialogDescription className="flex flex-wrap items-center gap-1.5">
             {i18next.t("agentConfig:From")}
diff --git a/web/src/components/agent-config/prompt-dialog.tsx 
b/web/src/components/agent-config/prompt-dialog.tsx
new file mode 100644
index 0000000..6e24f1d
--- /dev/null
+++ b/web/src/components/agent-config/prompt-dialog.tsx
@@ -0,0 +1,166 @@
+// 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 i18next from "i18next";
+
+import * as AgentConfigBackend from "@/backend/AgentConfigBackend";
+import * as Setting from "@/Setting";
+import {Loading} from "@/components/shared/loading";
+import {CodeText} from "@/components/shared/misc";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {Textarea} from "@/components/ui/textarea";
+import {formatBytes, formatModified} from "@/lib/agent-configs";
+import type {AgentConfigItem} from "@/types";
+
+/** The instruction file on screen, and the agent it belongs to. */
+export interface PromptTarget {
+  item: AgentConfigItem;
+  agentName: string;
+}
+
+/**
+ * Edits the instructions one agent reads before every session. The file is
+ * loaded when the dialog opens rather than taken from the listing, so a save
+ * writes over what is on disk now and not over a stale scan.
+ */
+export function PromptDialog({
+  target,
+  onOpenChange,
+  onSaved,
+}: {
+  target: PromptTarget | null;
+  onOpenChange: (open: boolean) => void;
+  onSaved: () => void;
+}) {
+  const [content, setContent] = React.useState("");
+  const [loaded, setLoaded] = React.useState("");
+  const [loading, setLoading] = React.useState(false);
+  const [saving, setSaving] = React.useState(false);
+  const [error, setError] = React.useState("");
+
+  const item = target?.item;
+
+  React.useEffect(() => {
+    if (!item) {
+      return;
+    }
+
+    let current = true;
+    setContent("");
+    setLoaded("");
+    setError("");
+    setLoading(true);
+    AgentConfigBackend.getAgentConfigItem(item.agentId, item.owner, item.kind, 
item.name)
+      .then(res => {
+        if (!current) {
+          return;
+        }
+        if (res.status === "ok") {
+          setContent(res.data?.content ?? "");
+          setLoaded(res.data?.content ?? "");
+        } else {
+          setError(res.msg || i18next.t("agentConfig:Failed to read this 
item"));
+        }
+      })
+      .catch(err => current && setError(err.message || String(err)))
+      .then(() => current && setLoading(false));
+
+    return () => {
+      current = false;
+    };
+  }, [item]);
+
+  const save = () => {
+    if (!item) {
+      return;
+    }
+
+    setSaving(true);
+    setError("");
+    AgentConfigBackend.saveAgentConfigPrompt(item.agentId, item.owner, content)
+      .then(res => {
+        if (res.status !== "ok") {
+          setError(res.msg || i18next.t("agentConfig:Failed to save these 
instructions"));
+          return;
+        }
+        Setting.showMessage("success", i18next.t("agentConfig:Instructions 
saved"));
+        onSaved();
+        onOpenChange(false);
+      })
+      .catch(err => setError(err.message || String(err)))
+      .then(() => setSaving(false));
+  };
+
+  return (
+    <Dialog open={Boolean(target)} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[85vh] gap-3 sm:max-w-3xl">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <span className="truncate">{item?.name}</span>
+            <Badge variant="muted">{target?.agentName}</Badge>
+          </DialogTitle>
+          <DialogDescription>{i18next.t("agentConfig:Prompt editor 
hint")}</DialogDescription>
+        </DialogHeader>
+
+        <div className="flex flex-col gap-3 overflow-y-auto">
+          <div className="flex flex-wrap items-center gap-2 text-xs">
+            <span 
className="text-muted-foreground">{i18next.t("general:Path")}</span>
+            <CodeText copyable>{item?.path}</CodeText>
+            {item?.missing ? (
+              <Badge variant="muted">{i18next.t("agentConfig:Not written 
yet")}</Badge>
+            ) : (
+              <span className="text-muted-foreground">
+                {formatBytes(item?.bytes)}
+                {formatModified(item?.modified) ? ` · 
${formatModified(item?.modified)}` : ""}
+              </span>
+            )}
+          </div>
+
+          {error ? <MessageAlert description={error} /> : null}
+          {loading ? (
+            <Loading />
+          ) : (
+            <Textarea
+              value={content}
+              onChange={event => setContent(event.target.value)}
+              spellCheck={false}
+              className="max-h-[55vh] min-h-[45vh] font-mono text-xs"
+              placeholder={i18next.t("agentConfig:Prompt placeholder")}
+            />
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => onOpenChange(false)}>
+            {i18next.t("general:Cancel")}
+          </Button>
+          <Button onClick={save} disabled={loading || saving || content === 
loaded}>
+            {i18next.t("general:Save")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/web/src/components/agent-config/trash-dialog.tsx 
b/web/src/components/agent-config/trash-dialog.tsx
index 5907fa5..56f48bf 100644
--- a/web/src/components/agent-config/trash-dialog.tsx
+++ b/web/src/components/agent-config/trash-dialog.tsx
@@ -146,7 +146,13 @@ export function TrashDialog({
                   <span className="truncate text-sm 
font-medium">{entry.name}</span>
                   <Badge variant="muted">{agentNames.get(entry.agentId) ?? 
entry.agentId}</Badge>
                   <Badge variant="outline">
-                    {i18next.t(entry.kind === "skill" ? "agentConfig:Skill" : 
"agentConfig:MCP")}
+                    {i18next.t(
+                      entry.kind === "skill"
+                        ? "agentConfig:Skill"
+                        : entry.kind === "prompt"
+                          ? "agentConfig:Prompt"
+                          : "agentConfig:MCP",
+                    )}
                   </Badge>
                 </div>
                 <p className="text-muted-foreground truncate text-xs">
diff --git a/web/src/lib/agent-configs.ts b/web/src/lib/agent-configs.ts
index ce08c78..a7e4961 100644
--- a/web/src/lib/agent-configs.ts
+++ b/web/src/lib/agent-configs.ts
@@ -24,22 +24,31 @@ import type {
 } from "@/types";
 import type {BadgeVariant} from "@/components/ui/badge";
 
-/** Skills and MCP servers belong to an account, so an id alone is not unique. 
*/
+/** An agent's configuration belongs to an account, so an id alone is not 
unique. */
 export function inventoryKey(inventory: Pick<AgentConfigInventory, "agentId" | 
"owner">) {
   return `${inventory.owner}:${inventory.agentId}`;
 }
 
 export function itemsOf(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
-  return kind === "skill" ? inventory.skills : inventory.mcpServers;
+  if (kind === "skill") {
+    return inventory.skills;
+  }
+  return kind === "prompt" ? inventory.prompts : inventory.mcpServers;
 }
 
 export function supports(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
-  return kind === "skill" ? inventory.skillsSupported : inventory.mcpSupported;
+  if (kind === "skill") {
+    return inventory.skillsSupported;
+  }
+  return kind === "prompt" ? inventory.promptSupported : 
inventory.mcpSupported;
 }
 
 /** Where the items of one kind live, shown under the agent it belongs to. */
 export function locationOf(inventory: AgentConfigInventory, kind: 
AgentConfigKind) {
-  return (kind === "skill" ? inventory.skillsDir : inventory.mcpFile) ?? "";
+  if (kind === "skill") {
+    return inventory.skillsDir ?? "";
+  }
+  return (kind === "prompt" ? inventory.promptFile : inventory.mcpFile) ?? "";
 }
 
 /**
@@ -59,11 +68,21 @@ export function blockedReason(inventory: 
AgentConfigInventory, kind: AgentConfig
 /**
  * The name two agents' copies of one item share. A skill from a plugin or from
  * a group inside the skills directory carries that in its name, but it is the
- * same skill once copied, so comparisons key on the last part.
+ * same skill once copied, so comparisons key on the last part. An item the
+ * server matches some other way says so itself.
  */
-export function sharedName(name: string) {
-  const cut = Math.max(name.lastIndexOf(":"), name.lastIndexOf("/"));
-  return cut < 0 ? name : name.slice(cut + 1);
+export function sharedName(item: AgentConfigItem) {
+  if (item.shared) {
+    return item.shared;
+  }
+  const cut = Math.max(item.name.lastIndexOf(":"), item.name.lastIndexOf("/"));
+  return cut < 0 ? item.name : item.name.slice(cut + 1);
+}
+
+/** What a row is titled: the file or folder, not the key it is matched by. */
+export function displayName(item: AgentConfigItem) {
+  const cut = Math.max(item.name.lastIndexOf(":"), item.name.lastIndexOf("/"));
+  return cut < 0 ? item.name : item.name.slice(cut + 1);
 }
 
 /** The item of each name every agent holds, keyed by shared name then agent. 
*/
@@ -71,11 +90,11 @@ export function copiesOf(inventories: 
AgentConfigInventory[], kind: AgentConfigK
   const copies = new Map<string, Map<string, AgentConfigItem>>();
   inventories.forEach(inventory => {
     itemsOf(inventory, kind).forEach(item => {
-      const holders = copies.get(sharedName(item.name)) ?? new Map<string, 
AgentConfigItem>();
+      const holders = copies.get(sharedName(item)) ?? new Map<string, 
AgentConfigItem>();
       if (!holders.has(inventoryKey(inventory))) {
         holders.set(inventoryKey(inventory), item);
       }
-      copies.set(sharedName(item.name), holders);
+      copies.set(sharedName(item), holders);
     });
   });
   return copies;
@@ -100,7 +119,7 @@ export function newerHolders(
   copies: Map<string, Map<string, AgentConfigItem>>,
   peers: AgentConfigInventory[],
 ) {
-  const holders = copies.get(sharedName(item.name));
+  const holders = copies.get(sharedName(item));
   if (!holders) {
     return [];
   }
@@ -212,15 +231,20 @@ export function formatBytes(bytes: number | undefined) {
   return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} 
${units[unit]}`;
 }
 
+/** Whether this item can be picked for a copy to other agents. */
+export function selectable(item: AgentConfigItem) {
+  return !item.managed && !item.missing;
+}
+
 /** The one-line summary of an MCP server: what it runs, or what it connects 
to. */
 export function endpointOf(item: AgentConfigItem) {
   return item.command || item.url || "";
 }
 
 /**
- * useAgentConfigs owns the host scan behind the Skills & MCP page. Deleting 
and
- * copying both change files this page has already read, so every one of them
- * ends by calling refresh().
+ * useAgentConfigs owns the host scan behind the Skills, MCP & Prompts page.
+ * Deleting, editing and copying all change files this page has already read, 
so
+ * every one of them ends by calling refresh().
  */
 export function useAgentConfigs() {
   const [inventories, setInventories] = 
React.useState<AgentConfigInventory[]>([]);
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index bba9d67..01163c7 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -181,6 +181,7 @@
     "Copy 1 MCP server": "Copy 1 MCP server",
     "Copy 1 item": "Copy 1 item",
     "Copy 1 skill": "Copy 1 skill",
+    "Copy these instructions": "Copy these instructions",
     "Copy to other agents": "Copy to other agents",
     "Copy {count} MCP servers": "Copy {count} MCP servers",
     "Copy {count} items": "Copy {count} items",
@@ -190,6 +191,7 @@
     "Delete description": "This removes it from the agent's own configuration, 
here:",
     "Delete for good description": "This erases it from the recycle bin, and 
it cannot be restored afterwards.",
     "Delete for good?": "Delete for good?",
+    "Delete these instructions?": "Delete these instructions?",
     "Delete this MCP server?": "Delete this MCP server?",
     "Delete this skill?": "Delete this skill?",
     "Delete undo hint": "It goes to Gateway's recycle bin first, so this can 
be undone.",
@@ -211,6 +213,7 @@
     "Failed to read the recycle bin": "Failed to read the recycle bin",
     "Failed to read this item": "Failed to read this item",
     "Failed to restore": "Failed to restore",
+    "Failed to save these instructions": "Failed to save these instructions",
     "Failed to update": "Failed to update",
     "From": "From",
     "From a plugin detail": "Shipped by the {plugin} plugin. Uninstall the 
plugin to remove it.",
@@ -218,6 +221,7 @@
     "Gateway does not know where this agent keeps these": "Gateway does not 
know where this agent keeps these",
     "HTTP endpoint": "HTTP endpoint",
     "Headers": "Headers",
+    "Instructions saved": "Instructions saved",
     "Local command": "Local command (stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP servers",
@@ -230,18 +234,26 @@
     "No MCP servers yet": "This agent has no MCP servers yet",
     "No agents found": "No agents found on this machine",
     "No agents found detail": "Gateway reads the configuration of the agents 
installed where it runs. Inside a container it sees the container's own 
filesystem, not the host's.",
+    "No instructions yet": "This agent has no instructions yet",
     "No other agent on this machine can receive these": "No other agent on 
this machine can receive these.",
     "No skills yet": "This agent has no skills yet",
     "Not installed": "config only",
     "Not installed detail": "Gateway found this configuration on disk but did 
not detect an installation of the agent.",
+    "Not written yet": "Not written yet",
+    "Not written yet detail": "This agent would read this file, but nobody has 
written it. Editing it creates it.",
     "Nothing deleted": "The recycle bin is empty",
-    "Nothing deleted detail": "Skills and MCP servers deleted on this page 
wait here for 30 days before they are erased.",
+    "Nothing deleted detail": "Skills, MCP servers and instructions deleted on 
this page wait here for 30 days before they are really gone.",
+    "Nothing here": "Nothing here",
     "Older version here": "An older version is here",
     "Out of date": "Out of date",
     "Out of date detail": "{agents} hold a newer version of this.",
-    "Page description": "The skills and MCP servers each agent on this machine 
keeps in its own configuration files. Compare them side by side, and copy them 
from one agent to another.",
+    "Page description": "The skills, MCP servers and instructions each agent 
on this machine keeps in its own configuration files. Compare them side by 
side, and copy them from one agent to another.",
     "Pairs hint": "One KEY=VALUE per line.",
     "Pick the agents to copy into": "pick the agents to copy into",
+    "Prompt": "Prompt",
+    "Prompt editor hint": "What this agent is told before every session. It is 
saved to the agent's own file, and a session already running has to be 
restarted to pick it up.",
+    "Prompt placeholder": "Nothing here yet. Anything written here is read by 
this agent at the start of every session.",
+    "Prompts": "Prompts",
     "Read from": "Read from",
     "Recycle bin": "Recycle bin",
     "Recycle bin hint": "What deleting removed, kept for 30 days. Restoring 
puts it back where it was.",
@@ -258,7 +270,7 @@
     "Shared with": "The same files are read by {agents}.",
     "Skill": "Skill",
     "Skills": "Skills",
-    "Skills & MCP": "Skills & MCP",
+    "Skills, MCP & Prompts": "Skills, MCP & Prompts",
     "Skipped": "Skipped",
     "Source": "Source",
     "This file is read-only for Gateway": "Gateway does not write this agent's 
configuration file",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0693f81..f619c1d 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -181,6 +181,7 @@
     "Copy 1 MCP server": "复制 1 个 MCP 服务器",
     "Copy 1 item": "复制 1 项",
     "Copy 1 skill": "复制 1 个技能",
+    "Copy these instructions": "复制这份提示词",
     "Copy to other agents": "复制到其他 Agent",
     "Copy {count} MCP servers": "复制 {count} 个 MCP 服务器",
     "Copy {count} items": "复制 {count} 项",
@@ -190,6 +191,7 @@
     "Delete description": "这会把它从该 Agent 自己的配置中删除,位置:",
     "Delete for good description": "将从回收站中彻底删除,之后无法恢复。",
     "Delete for good?": "彻底删除?",
+    "Delete these instructions?": "删除这份提示词?",
     "Delete this MCP server?": "删除这个 MCP 服务器?",
     "Delete this skill?": "删除这个技能?",
     "Delete undo hint": "会先移入网关的回收站,可以随时撤销。",
@@ -211,6 +213,7 @@
     "Failed to read the recycle bin": "读取回收站失败",
     "Failed to read this item": "读取该项失败",
     "Failed to restore": "恢复失败",
+    "Failed to save these instructions": "保存提示词失败",
     "Failed to update": "更新失败",
     "From": "从",
     "From a plugin detail": "由插件 {plugin} 提供,卸载该插件才能移除。",
@@ -218,6 +221,7 @@
     "Gateway does not know where this agent keeps these": "Gateway 不知道该 Agent 
把这些配置放在哪里",
     "HTTP endpoint": "HTTP 端点",
     "Headers": "请求头",
+    "Instructions saved": "提示词已保存",
     "Local command": "本地命令(stdio)",
     "MCP": "MCP",
     "MCP servers": "MCP 服务器",
@@ -230,18 +234,26 @@
     "No MCP servers yet": "该 Agent 还没有 MCP 服务器",
     "No agents found": "本机没有找到 Agent",
     "No agents found detail": "Gateway 只读取它自己所在机器上安装的 Agent 
的配置。在容器里运行时,它看到的是容器自己的文件系统,而不是宿主机的。",
+    "No instructions yet": "这个 Agent 还没有提示词",
     "No other agent on this machine can receive these": "本机没有其他 Agent 
可以接收这些内容。",
     "No skills yet": "该 Agent 还没有技能",
     "Not installed": "仅配置",
     "Not installed detail": "Gateway 在磁盘上找到了这份配置,但没有检测到该 Agent 的安装。",
+    "Not written yet": "尚未写入",
+    "Not written yet detail": "这个 Agent 会读这个文件,但还没有人写过它。编辑即会创建。",
     "Nothing deleted": "回收站是空的",
-    "Nothing deleted detail": "在本页删除的技能和 MCP 服务会在这里保留 30 天,然后才真正删除。",
+    "Nothing deleted detail": "在本页删除的技能、MCP 服务器和提示词会在这里保留 30 天,然后才真正删除。",
+    "Nothing here": "空",
     "Older version here": "这里是更旧的版本",
     "Out of date": "有更新",
     "Out of date detail": "{agents} 上的版本比这里新。",
-    "Page description": "本机每个 Agent 自己配置文件里的技能和 MCP 服务器。可以并排比较,并从一个 Agent 
复制到另一个。",
+    "Page description": "本机每个 Agent 自己配置文件里的技能、MCP 服务器和提示词。可以并排比较,并从一个 Agent 
复制到另一个。",
     "Pairs hint": "每行一个 KEY=VALUE。",
     "Pick the agents to copy into": "选择要复制到的 Agent",
+    "Prompt": "提示词",
+    "Prompt editor hint": "每次会话开始前这个 Agent 会读到的内容。保存后写入 Agent 
自己的文件,已经在运行的会话需要重启才会生效。",
+    "Prompt placeholder": "目前是空的。写在这里的内容,这个 Agent 每次会话开始时都会读到。",
+    "Prompts": "提示词",
     "Read from": "读取自",
     "Recycle bin": "回收站",
     "Recycle bin hint": "删除的内容会在这里保留 30 天,恢复后回到原来的位置。",
@@ -258,7 +270,7 @@
     "Shared with": "{agents} 读取的是同一批文件。",
     "Skill": "技能",
     "Skills": "技能",
-    "Skills & MCP": "技能与 MCP",
+    "Skills, MCP & Prompts": "技能、MCP 与提示词",
     "Skipped": "跳过",
     "Source": "来源",
     "This file is read-only for Gateway": "Gateway 不会写入该 Agent 的配置文件",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 24e2069..71b9553 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -70,7 +70,7 @@ export const navGroups: NavGroup[] = [
   {key: "/agents", label: "agent:Advanced view", icon: Table2, path: 
"/agents", adminOnly: true, hidden: true},
   {
     key: "/agent-configs",
-    label: "agentConfig:Skills & MCP",
+    label: "agentConfig:Skills, MCP & Prompts",
     icon: Blocks,
     path: "/agent-configs",
     adminOnly: true,
diff --git a/web/src/pages/AgentConfigsPage.tsx 
b/web/src/pages/AgentConfigsPage.tsx
index 296dd94..1990b45 100644
--- a/web/src/pages/AgentConfigsPage.tsx
+++ b/web/src/pages/AgentConfigsPage.tsx
@@ -20,6 +20,7 @@ import {
   Eye,
   Minus,
   Package,
+  Pencil,
   Plus,
   RefreshCw,
   Send,
@@ -33,6 +34,7 @@ import {AgentIcon} from "@/components/AgentIcon";
 import {AddMcpDialog} from "@/components/agent-config/add-mcp-dialog";
 import {CopyDialog} from "@/components/agent-config/copy-dialog";
 import {DetailDialog, type DetailTarget} from 
"@/components/agent-config/detail-dialog";
+import {PromptDialog, type PromptTarget} from 
"@/components/agent-config/prompt-dialog";
 import {TrashDialog} from "@/components/agent-config/trash-dialog";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {DataTable, type Column} from "@/components/shared/data-table";
@@ -51,6 +53,7 @@ import {
   compareVersions,
   copiesOf,
   counted,
+  displayName,
   endpointOf,
   formatBytes,
   formatModified,
@@ -59,6 +62,7 @@ import {
   locationOf,
   newerHolders,
   originTitle,
+  selectable as canSelect,
   sharedName,
   supports,
   updateBadge,
@@ -105,6 +109,9 @@ function SourcePicker({
                 {i18next.t("agentConfig:Skills")} {inventory.skills.length}
                 {" · "}
                 {i18next.t("agentConfig:MCP")} {inventory.mcpServers.length}
+                {inventory.promptSupported
+                  ? ` · ${i18next.t("agentConfig:Prompts")} 
${inventory.prompts.filter(prompt => !prompt.missing).length}`
+                  : ""}
               </span>
             </span>
           </button>
@@ -152,6 +159,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   const [sourceKey, setSourceKey] = React.useState("");
   const [selected, setSelected] = React.useState<string[]>([]);
   const [detail, setDetail] = React.useState<DetailTarget | null>(null);
+  const [editing, setEditing] = React.useState<PromptTarget | null>(null);
   const [copyOpen, setCopyOpen] = React.useState(false);
   const [addOpen, setAddOpen] = React.useState(false);
   const [trashOpen, setTrashOpen] = React.useState(false);
@@ -168,7 +176,8 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   // source the reader is working from.
   React.useEffect(() => {
     if (sourceKey === "" && inventories.length > 0) {
-      const landing = inventories.find(inventory => itemsOf(inventory, 
kind).length > 0) ?? inventories[0];
+      const landing =
+        inventories.find(inventory => itemsOf(inventory, 
kind).some(canSelect)) ?? inventories[0];
       setSourceKey(inventoryKey(landing));
     }
   }, [inventories, kind, sourceKey]);
@@ -184,7 +193,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   const peers = source ? inventories.filter(inventory => 
inventoryKey(inventory) !== inventoryKey(source)) : [];
   const items = source ? itemsOf(source, kind) : [];
   const copies = copiesOf(inventories, kind);
-  const selectable = items.filter(item => !item.managed);
+  const selectable = items.filter(canSelect);
   const agentNames = new Map(inventories.map(inventory => [inventory.agentId, 
inventory.name]));
   const allSelected = selectable.length > 0 && selected.length === 
selectable.length;
 
@@ -243,7 +252,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         />
       ),
       render: (_value, record) =>
-        record.managed ? null : (
+        !canSelect(record) ? null : (
           <input
             type="checkbox"
             className="accent-primary size-4 align-middle"
@@ -264,7 +273,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         return (
           <div className="min-w-0">
             <div className="flex flex-wrap items-center gap-1.5">
-              <span className="truncate 
font-medium">{sharedName(record.name)}</span>
+              <span className="truncate 
font-medium">{displayName(record)}</span>
               {record.origin ? (
                 <SimpleTooltip title={originTitle(record)}>
                   <Badge variant="muted">{record.origin}</Badge>
@@ -289,6 +298,11 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
                   <Badge variant="info">{i18next.t("agentConfig:Managed by 
Gateway")}</Badge>
                 </SimpleTooltip>
               ) : null}
+              {record.missing ? (
+                <SimpleTooltip title={i18next.t("agentConfig:Not written yet 
detail")}>
+                  <Badge variant="muted">{i18next.t("agentConfig:Not written 
yet")}</Badge>
+                </SimpleTooltip>
+              ) : null}
             </div>
             {record.description ? (
               <p className="text-muted-foreground line-clamp-2 
text-xs">{record.description}</p>
@@ -300,9 +314,22 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
     {
       key: "summary",
       width: "220px",
-      title: kind === "skill" ? i18next.t("agentConfig:Contents") : 
i18next.t("agentConfig:Endpoint"),
+      title:
+        kind === "mcp" ? i18next.t("agentConfig:Endpoint") : 
i18next.t("agentConfig:Contents"),
       render: (_value, record) =>
-        kind === "skill" ? (
+        kind === "prompt" ? (
+          <span className="text-muted-foreground text-xs">
+            {record.missing || !record.bytes
+              ? i18next.t("agentConfig:Nothing here")
+              : formatBytes(record.bytes)}
+            {formatModified(record.modified) ? (
+              <>
+                <br />
+                {`${i18next.t("agentConfig:Changed")} 
${formatModified(record.modified)}`}
+              </>
+            ) : null}
+          </span>
+        ) : kind === "skill" ? (
           <span className="text-muted-foreground text-xs">
             {counted(record.files ?? 0, "agentConfig:1 file", 
"agentConfig:{files} files", "{files}")}
             {record.bytes ? ` · ${formatBytes(record.bytes)}` : ""}
@@ -338,8 +365,11 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
         if (!supports(peer, kind)) {
           return <span className="text-muted-foreground/50 text-xs">—</span>;
         }
-        const other = 
copies.get(sharedName(record.name))?.get(inventoryKey(peer));
-        return <PeerCell state={other ? compareVersions(record, other) : 
"missing"} />;
+        const other = copies.get(sharedName(record))?.get(inventoryKey(peer));
+        // An instruction file is listed even when nobody has written it, so a
+        // peer that has none holds nothing rather than a different version.
+        const held = other && !other.missing && !record.missing ? other : 
undefined;
+        return <PeerCell state={held ? compareVersions(record, held) : 
"missing"} />;
       },
     })),
     {
@@ -375,19 +405,36 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
               </SimpleTooltip>
             </ConfirmDialog>
           ) : null}
-          <SimpleTooltip title={i18next.t("agentConfig:View")}>
-            <Button
-              variant="ghost"
-              size="icon-sm"
-              aria-label={i18next.t("agentConfig:View")}
-              onClick={() => setDetail({item: record, agentName: source?.name 
?? record.agentId})}
-            >
-              <Eye className="size-4" />
-            </Button>
-          </SimpleTooltip>
+          {kind === "prompt" ? (
+            <SimpleTooltip title={i18next.t("general:Edit")}>
+              <Button
+                variant="ghost"
+                size="icon-sm"
+                aria-label={i18next.t("general:Edit")}
+                onClick={() => setEditing({item: record, agentName: 
source?.name ?? record.agentId})}
+              >
+                <Pencil className="size-4" />
+              </Button>
+            </SimpleTooltip>
+          ) : (
+            <SimpleTooltip title={i18next.t("agentConfig:View")}>
+              <Button
+                variant="ghost"
+                size="icon-sm"
+                aria-label={i18next.t("agentConfig:View")}
+                onClick={() => setDetail({item: record, agentName: 
source?.name ?? record.agentId})}
+              >
+                <Eye className="size-4" />
+              </Button>
+            </SimpleTooltip>
+          )}
           <ConfirmDialog
             title={i18next.t(
-              kind === "skill" ? "agentConfig:Delete this skill?" : 
"agentConfig:Delete this MCP server?",
+              kind === "skill"
+                ? "agentConfig:Delete this skill?"
+                : kind === "prompt"
+                  ? "agentConfig:Delete these instructions?"
+                  : "agentConfig:Delete this MCP server?",
             )}
             description={
               <span className="flex flex-col gap-1">
@@ -422,7 +469,7 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
   return (
     <PageContainer>
       <PageHeader
-        title={i18next.t("agentConfig:Skills & MCP")}
+        title={i18next.t("agentConfig:Skills, MCP & Prompts")}
         description={i18next.t("agentConfig:Page description")}
         actions={
           <>
@@ -477,6 +524,10 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
                   {i18next.t("agentConfig:MCP servers")}
                   <Badge variant="muted">{source.mcpServers.length}</Badge>
                 </TabsTrigger>
+                <TabsTrigger value="prompt">
+                  {i18next.t("agentConfig:Prompts")}
+                  <Badge variant="muted">{source.prompts.filter(prompt => 
!prompt.missing).length}</Badge>
+                </TabsTrigger>
               </TabsList>
             </Tabs>
 
@@ -522,7 +573,11 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
               pageSize={20}
               emptyIcon={Package}
               emptyText={i18next.t(
-                kind === "skill" ? "agentConfig:No skills yet" : 
"agentConfig:No MCP servers yet",
+                kind === "skill"
+                  ? "agentConfig:No skills yet"
+                  : kind === "prompt"
+                    ? "agentConfig:No instructions yet"
+                    : "agentConfig:No MCP servers yet",
               )}
               toolbar={
                 <Button disabled={selected.length === 0} onClick={() => 
setCopyOpen(true)}>
@@ -568,6 +623,12 @@ export default function AgentConfigsPage({account}: 
{account: Account}) {
       />
 
       <DetailDialog target={detail} onOpenChange={open => !open && 
setDetail(null)} />
+
+      <PromptDialog
+        target={editing}
+        onOpenChange={open => !open && setEditing(null)}
+        onSaved={refresh}
+      />
     </PageContainer>
   );
 }
diff --git a/web/src/types.ts b/web/src/types.ts
index 1542d9f..5d0ad24 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -500,16 +500,25 @@ export interface Setting {
   dbPass: string;
 }
 
-/** The two kinds of configuration the Skills & MCP page manages. */
-export type AgentConfigKind = "skill" | "mcp";
+/** The kinds of configuration the Skills, MCP & Prompts page manages. */
+export type AgentConfigKind = "skill" | "mcp" | "prompt";
 
 export interface AgentConfigItem {
   agentId: string;
   owner: string;
   kind: AgentConfigKind;
   name: string;
+  /**
+   * What two agents' copies of this item are matched by, when that is not the
+   * name: every agent's instruction file holds the same thing under its own
+   * file name.
+   */
+  shared?: string;
   description?: string;
-  /** The skill's own folder, or the config file the MCP server is an entry 
of. */
+  /**
+   * The skill's own folder, the config file the MCP server is an entry of, or
+   * the instruction file itself.
+   */
   path: string;
   transport?: string;
   command?: string;
@@ -535,6 +544,8 @@ export interface AgentConfigItem {
   managed?: boolean;
   /** Why Gateway will not delete this item. Empty when it may. */
   readOnly?: string;
+  /** Listed because the agent would read it, not because it is there. */
+  missing?: boolean;
 }
 
 /**
@@ -556,7 +567,7 @@ export interface AgentConfigSkillUpdate {
   copiedAt?: number;
 }
 
-/** One installation's skills and MCP servers, as they exist in its own files. 
*/
+/** One installation's configuration, as it exists in its own files. */
 export interface AgentConfigInventory {
   agentId: string;
   owner: string;
@@ -572,12 +583,16 @@ export interface AgentConfigInventory {
   skillsDir?: string;
   skillsDirs?: string[];
   mcpFile?: string;
+  /** The one Markdown file this agent reads before every session. */
+  promptFile?: string;
   skillsSupported: boolean;
   mcpSupported: boolean;
+  promptSupported: boolean;
   mcpWritable: boolean;
   mcpReadOnly?: string;
   skills: AgentConfigItem[];
   mcpServers: AgentConfigItem[];
+  prompts: AgentConfigItem[];
   errors?: string[];
 }
 


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

Reply via email to