diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs
index 5b007574bb..e1b2c2a81d 100644
--- a/crates/goose-sdk/src/custom_requests.rs
+++ b/crates/goose-sdk/src/custom_requests.rs
@@ -124,6 +124,13 @@ pub enum SessionSystemPromptMode {
/// `mode: "set"` replaces Goose's base system prompt. `mode: "append"` adds an
/// instruction under "Additional Instructions". Reusing a key replaces the
/// previous value for that mode/key; sending empty text clears it.
+///
+/// Persistence: values set with `mode = Set`, or with `mode = Append` and a
+/// `key` starting with `client_`, are persisted to the session record and
+/// rehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`,
+/// `final_output`, `additional`, `hints_*`) are server-managed and only mutate
+/// the in-memory PromptManager for the current process; they're rebuilt from
+/// session state on the next agent spawn.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(
method = "_goose/unstable/session/system-prompt/set",
diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs
index afff2d7c93..3515b6c1cf 100644
--- a/crates/goose-server/src/openapi.rs
+++ b/crates/goose-server/src/openapi.rs
@@ -570,6 +570,7 @@ derive_utoipa!(IconTheme as IconThemeSchema);
ThinkingEffort,
super::routes::config_management::ProviderModelInfoQuery,
Session,
+ goose::session::session_manager::ClientSystemPrompt,
goose::config::goose_mode::GooseMode,
SessionInsights,
SessionType,
diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json
index 9afa77e6c4..57b2cd3ec0 100644
--- a/crates/goose/acp-schema.json
+++ b/crates/goose/acp-schema.json
@@ -191,7 +191,7 @@
"sessionId",
"text"
],
- "description": "Set, append, or clear system prompt text for a session.\n\n`mode: \"set\"` replaces Goose's base system prompt. `mode: \"append\"` adds an\ninstruction under \"Additional Instructions\". Reusing a key replaces the\nprevious value for that mode/key; sending empty text clears it.",
+ "description": "Set, append, or clear system prompt text for a session.\n\n`mode: \"set\"` replaces Goose's base system prompt. `mode: \"append\"` adds an\ninstruction under \"Additional Instructions\". Reusing a key replaces the\nprevious value for that mode/key; sending empty text clears it.\n\nPersistence: values set with `mode = Set`, or with `mode = Append` and a\n`key` starting with `client_`, are persisted to the session record and\nrehydrated when the session is loaded. Other `Append` keys (e.g. `recipe`,\n`final_output`, `additional`, `hints_*`) are server-managed and only mutate\nthe in-memory PromptManager for the current process; they're rebuilt from\nsession state on the next agent spawn.",
"x-side": "agent",
"x-method": "_goose/unstable/session/system-prompt/set"
},
diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs
index 6d06a0b5f7..0b14271be7 100644
--- a/crates/goose/src/acp/server.rs
+++ b/crates/goose/src/acp/server.rs
@@ -1557,6 +1557,15 @@ impl GooseAcpAgent {
.await
.map_err(|e| e.to_string())?;
+ if let Some(persisted) = goose_session.client_system_prompt.as_ref() {
+ if let Some(text) = persisted.override_text.as_ref() {
+ agent.override_system_prompt(text.clone()).await;
+ }
+ for (key, text) in &persisted.extras {
+ agent.extend_system_prompt(key.clone(), text.clone()).await;
+ }
+ }
+
Ok(agent)
}
.await;
@@ -2392,6 +2401,35 @@ fn extract_tool_call_update_meta(
Some(meta_map)
}
+fn sha256_short(s: &str) -> String {
+ let digest = Sha256::digest(s.as_bytes());
+ digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
+}
+
+/// Build a `_meta.clientSystemPrompt` map of `key -> sha256_short(value)`
+/// fingerprints for the session's persisted client-authored system prompt
+/// state. Returns `None` when nothing is persisted, so the meta field is
+/// omitted entirely in that case.
+fn client_system_prompt_meta(session: &Session) -> Option {
+ let csp = session.client_system_prompt.as_ref()?;
+ let mut keys = serde_json::Map::new();
+ if let Some(text) = &csp.override_text {
+ keys.insert("override".to_string(), sha256_short(text).into());
+ }
+ for (key, value) in &csp.extras {
+ keys.insert(key.clone(), sha256_short(value).into());
+ }
+ if keys.is_empty() {
+ return None;
+ }
+ let mut meta = serde_json::Map::new();
+ meta.insert(
+ "clientSystemPrompt".to_string(),
+ serde_json::Value::Object(keys),
+ );
+ Some(meta)
+}
+
fn replay_message_meta(message: &Message) -> Meta {
let mut meta = serde_json::Map::new();
meta.insert(
@@ -2990,6 +3028,8 @@ impl GooseAcpAgent {
.prepare_session_init_config(&resolved, &mode_state, &goose_session)
.await;
+ let csp_meta = client_system_prompt_meta(&goose_session);
+
self.spawn_agent_setup(
cx,
agent_tx,
@@ -3009,6 +3049,9 @@ impl GooseAcpAgent {
if let Some(co) = config_options {
response = response.config_options(co);
}
+ if let Some(meta) = csp_meta {
+ response = response.meta(meta);
+ }
if let Some(usage_update) = initial_usage_update {
cx.send_notification(SessionNotification::new(
args.session_id.clone(),
@@ -4473,6 +4516,7 @@ print(\"hello, world\")
goose_mode: GooseMode::default(),
archived_at: None,
project_id: None,
+ client_system_prompt: None,
}
}
diff --git a/crates/goose/src/acp/server/sessions.rs b/crates/goose/src/acp/server/sessions.rs
index e46a5e75b3..ddfb7d033a 100644
--- a/crates/goose/src/acp/server/sessions.rs
+++ b/crates/goose/src/acp/server/sessions.rs
@@ -1,5 +1,15 @@
use super::*;
+/// Returns true if `key` (used with `SessionSystemPromptMode::Append`) is
+/// considered client-authored and should be persisted to the session record.
+///
+/// Server-managed keys (`recipe`, `final_output`, `additional`, `hints_*`,
+/// `chat_mode`, etc.) are rebuilt at agent-spawn time from other session
+/// state, so they must not be persisted via the client-driven path.
+pub(super) fn is_client_authored_key(key: &str) -> bool {
+ key.starts_with("client_")
+}
+
impl GooseAcpAgent {
pub(super) async fn on_update_working_dir(
&self,
@@ -38,20 +48,31 @@ impl GooseAcpAgent {
&self,
req: SetSessionSystemPromptRequest,
) -> Result {
- let session_id = req.session_id.trim();
+ let session_id = req.session_id.trim().to_string();
if session_id.is_empty() {
return Err(
agent_client_protocol::Error::invalid_params().data("sessionId cannot be empty")
);
}
- let agent = self.get_session_agent_provider_ready(session_id).await?;
- match req.mode {
+ let agent = self.get_session_agent_provider_ready(&session_id).await?;
+
+ enum PersistOp {
+ SetOverride(Option),
+ UpsertExtra { key: String, text: String },
+ RemoveExtra(String),
+ Skip,
+ }
+
+ let persist_op = match req.mode {
SessionSystemPromptMode::Set => {
- if req.text.trim().is_empty() {
+ let trimmed_empty = req.text.trim().is_empty();
+ if trimmed_empty {
agent.clear_system_prompt_override().await;
+ PersistOp::SetOverride(None)
} else {
- agent.override_system_prompt(req.text).await;
+ agent.override_system_prompt(req.text.clone()).await;
+ PersistOp::SetOverride(Some(req.text))
}
}
SessionSystemPromptMode::Append => {
@@ -63,13 +84,56 @@ impl GooseAcpAgent {
.ok_or_else(|| {
agent_client_protocol::Error::invalid_params()
.data("key cannot be empty for append mode")
- })?;
- if req.text.trim().is_empty() {
- agent.remove_system_prompt_extra(key).await;
+ })?
+ .to_string();
+ let trimmed_empty = req.text.trim().is_empty();
+ let op = if !is_client_authored_key(&key) {
+ warn!(
+ key = %key,
+ "system-prompt set: key not in client-authored allowlist; applied to in-memory PromptManager only (not persisted)"
+ );
+ PersistOp::Skip
+ } else if trimmed_empty {
+ PersistOp::RemoveExtra(key.clone())
} else {
- agent.extend_system_prompt(key.to_string(), req.text).await;
+ PersistOp::UpsertExtra {
+ key: key.clone(),
+ text: req.text.clone(),
+ }
+ };
+ if trimmed_empty {
+ agent.remove_system_prompt_extra(&key).await;
+ } else {
+ agent.extend_system_prompt(key, req.text).await;
}
+ op
}
+ };
+
+ if !matches!(persist_op, PersistOp::Skip) {
+ let session = self
+ .session_manager
+ .get_session(&session_id, false)
+ .await
+ .internal_err()?;
+ let mut state = session.client_system_prompt.unwrap_or_default();
+ match persist_op {
+ PersistOp::SetOverride(value) => state.override_text = value,
+ PersistOp::UpsertExtra { key, text } => {
+ state.extras.insert(key, text);
+ }
+ PersistOp::RemoveExtra(key) => {
+ state.extras.remove(&key);
+ }
+ PersistOp::Skip => unreachable!(),
+ }
+ let next = if state.is_empty() { None } else { Some(state) };
+ self.session_manager
+ .update(&session_id)
+ .client_system_prompt(next)
+ .apply()
+ .await
+ .internal_err()?;
}
Ok(EmptyResponse {})
diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs
index 20caf461d6..580ec6789a 100644
--- a/crates/goose/src/session/session_manager.rs
+++ b/crates/goose/src/session/session_manager.rs
@@ -12,14 +12,14 @@ use rmcp::model::Role;
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Pool, Sqlite};
-use std::collections::HashMap;
+use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use tracing::{info, warn};
use utoipa::ToSchema;
-pub const CURRENT_SCHEMA_VERSION: i32 = 13;
+pub const CURRENT_SCHEMA_VERSION: i32 = 14;
pub const SESSIONS_FOLDER: &str = "sessions";
pub const DB_NAME: &str = "sessions.db";
@@ -86,6 +86,29 @@ pub struct Session {
pub archived_at: Option>,
#[serde(default)]
pub project_id: Option,
+ #[serde(default)]
+ pub client_system_prompt: Option,
+}
+
+/// Client-authored system prompt state persisted with a session.
+///
+/// Populated via the ACP `_goose/unstable/session/system-prompt/set` method.
+/// `override_text` mirrors `PromptManager::system_prompt_override` (Set mode);
+/// `extras` mirrors a filtered subset of `PromptManager::system_prompt_extras`
+/// (Append mode, only keys whose persistence is allow-listed at the handler).
+#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct ClientSystemPrompt {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub override_text: Option,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub extras: BTreeMap,
+}
+
+impl ClientSystemPrompt {
+ pub fn is_empty(&self) -> bool {
+ self.override_text.is_none() && self.extras.is_empty()
+ }
}
pub struct SessionUpdateBuilder<'a> {
@@ -112,6 +135,7 @@ pub struct SessionUpdateBuilder<'a> {
archived_at: Option