diff --git a/docs/telegram-gateway-operator-guide.md b/docs/telegram-gateway-operator-guide.md
index e7861d8..cc1f4e4 100644
--- a/docs/telegram-gateway-operator-guide.md
+++ b/docs/telegram-gateway-operator-guide.md
@@ -110,12 +110,14 @@ The gateway container needs:
- Docker network
- `claw_gateway`
+- `ANTHROPIC_AUTH_TOKEN`
+ - Claude subscription OAuth token such as `sk-ant-oat...`
+ - the gateway now sends the extra Claude Code headers required for this path
+
- `ANTHROPIC_API_KEY`
+ - standard Anthropic API key
-Bearer-only auth is not currently supported by the Anthropic Messages API used by this runtime.
-If you pass only `ANTHROPIC_AUTH_TOKEN`, workers will fail with:
-
-- `401 Unauthorized (authentication_error): OAuth authentication is currently not supported.`
+If you use `ANTHROPIC_AUTH_TOKEN`, the gateway workers add the Claude Code OAuth headers and agent identity prefix that `nanobot` also uses for subscription auth.
The gateway copies inherited auth env vars into worker containers according to `CLAW_GATEWAY_INHERITED_ENV`.
@@ -156,7 +158,7 @@ Example:
"template_dir": "/unraid/templates-user",
"template_file_prefix": "claw-worker-",
"template_archive_dir": "/appdata/template-archive",
- "inherited_env": ["ANTHROPIC_API_KEY"]
+ "inherited_env": ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]
},
"worker_defaults": {
"bind_port": 8080,
diff --git a/my-claw-telegram.xml b/my-claw-telegram.xml
index 46e7383..a98678d 100644
--- a/my-claw-telegram.xml
+++ b/my-claw-telegram.xml
@@ -20,7 +20,7 @@
- Docker socket access and an Anthropic API key
+ Docker socket access and either an Anthropic OAuth token or Anthropic API key
/var/run/docker.sock
/boot/config/plugins/dockerMan/templates-user
/mnt/user/appdata/claw-telegram-gateway
@@ -33,9 +33,9 @@
/unraid/templates-user
/appdata/template-archive
claw_gateway
- ANTHROPIC_API_KEY
+ ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY
15
-
-
+
+
diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs
index f8b41ac..790e308 100644
--- a/rust/crates/api/src/providers/anthropic.rs
+++ b/rust/crates/api/src/providers/anthropic.rs
@@ -24,6 +24,9 @@ const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2);
const DEFAULT_MAX_RETRIES: u32 = 2;
+const CLAUDE_CODE_OAUTH_BETA_HEADER: &str =
+ "claude-code-20250219,oauth-2025-04-20,context-management-2025-06-27";
+const CLAUDE_CODE_OAUTH_USER_AGENT: &str = "claude-cli/2.1.2 (external, cli)";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthSource {
@@ -83,6 +86,12 @@ impl AuthSource {
}
}
+ #[must_use]
+ pub fn uses_claude_code_oauth(&self) -> bool {
+ self.bearer_token()
+ .is_some_and(is_claude_code_oauth_token)
+ }
+
pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(api_key) = self.api_key() {
request_builder = request_builder.header("x-api-key", api_key);
@@ -90,10 +99,22 @@ impl AuthSource {
if let Some(token) = self.bearer_token() {
request_builder = request_builder.bearer_auth(token);
}
+ if self.uses_claude_code_oauth() {
+ request_builder = request_builder
+ .header("anthropic-beta", CLAUDE_CODE_OAUTH_BETA_HEADER)
+ .header("anthropic-dangerous-direct-browser-access", "true")
+ .header("user-agent", CLAUDE_CODE_OAUTH_USER_AGENT)
+ .header("x-app", "cli");
+ }
request_builder
}
}
+#[must_use]
+pub fn is_claude_code_oauth_token(token: &str) -> bool {
+ token.contains("sk-ant-oat")
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct OAuthTokenSet {
pub access_token: String,
@@ -840,7 +861,10 @@ struct AnthropicErrorBody {
#[cfg(test)]
mod tests {
- use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
+ use super::{
+ ALT_REQUEST_ID_HEADER, CLAUDE_CODE_OAUTH_BETA_HEADER, CLAUDE_CODE_OAUTH_USER_AGENT,
+ REQUEST_ID_HEADER,
+ };
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Mutex, OnceLock};
@@ -1226,7 +1250,7 @@ mod tests {
fn auth_source_applies_headers() {
let auth = AuthSource::ApiKeyAndBearer {
api_key: "test-key".to_string(),
- bearer_token: "proxy-token".to_string(),
+ bearer_token: "sk-ant-oat01-proxy-token".to_string(),
};
let request = auth
.apply(reqwest::Client::new().post("https://example.test"))
@@ -1239,7 +1263,25 @@ mod tests {
);
assert_eq!(
headers.get("authorization").and_then(|v| v.to_str().ok()),
- Some("Bearer proxy-token")
+ Some("Bearer sk-ant-oat01-proxy-token")
+ );
+ assert_eq!(
+ headers.get("anthropic-beta").and_then(|v| v.to_str().ok()),
+ Some(CLAUDE_CODE_OAUTH_BETA_HEADER)
+ );
+ assert_eq!(
+ headers
+ .get("anthropic-dangerous-direct-browser-access")
+ .and_then(|v| v.to_str().ok()),
+ Some("true")
+ );
+ assert_eq!(
+ headers.get("x-app").and_then(|v| v.to_str().ok()),
+ Some("cli")
+ );
+ assert_eq!(
+ headers.get("user-agent").and_then(|v| v.to_str().ok()),
+ Some(CLAUDE_CODE_OAUTH_USER_AGENT)
);
}
}
diff --git a/rust/crates/channel-gateway-core/src/runtime_host.rs b/rust/crates/channel-gateway-core/src/runtime_host.rs
index ac201d3..14a0a04 100644
--- a/rust/crates/channel-gateway-core/src/runtime_host.rs
+++ b/rust/crates/channel-gateway-core/src/runtime_host.rs
@@ -29,6 +29,8 @@ use tools::{GlobalToolRegistry, RuntimeToolDefinition};
const DEFAULT_PROMPT_DATE: &str = "2026-03-31";
const APPROVAL_POLL_INTERVAL: Duration = Duration::from_millis(250);
+const CLAUDE_CODE_OAUTH_SYSTEM_PREFIX: &str =
+ "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
pub type AllowedToolSet = BTreeSet;
@@ -175,7 +177,8 @@ impl RuntimeHost {
event_tx: UnboundedSender,
) -> Result<(), HostError> {
let session = self.load_or_create_session(session_path)?;
- let system_prompt = build_system_prompt(&self.config.cwd)?;
+ let auth = resolve_cli_auth_source(&self.config.cwd)?;
+ let system_prompt = build_system_prompt(&self.config.cwd, &auth)?;
let mut runtime = build_runtime(
&self.config,
session,
@@ -183,6 +186,7 @@ impl RuntimeHost {
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("telegram-session"),
+ auth,
)?;
let mut observer = BridgeObserver::new(event_tx.clone());
let mut prompter = BridgePermissionPrompter::new(
@@ -263,9 +267,21 @@ impl From for HostError {
}
}
-fn build_system_prompt(cwd: &Path) -> Result, HostError> {
- load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown")
- .map_err(|error| HostError::Other(error.to_string()))
+fn build_system_prompt(cwd: &Path, auth: &AuthSource) -> Result, HostError> {
+ let system_prompt = load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown")
+ .map_err(|error| HostError::Other(error.to_string()))?;
+ Ok(with_claude_code_oauth_prefix(system_prompt, auth))
+}
+
+fn with_claude_code_oauth_prefix(mut system_prompt: Vec, auth: &AuthSource) -> Vec {
+ if auth.uses_claude_code_oauth()
+ && !system_prompt
+ .iter()
+ .any(|segment| segment.contains(CLAUDE_CODE_OAUTH_SYSTEM_PREFIX))
+ {
+ system_prompt.insert(0, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string());
+ }
+ system_prompt
}
#[derive(Debug, Clone)]
@@ -577,6 +593,7 @@ fn build_runtime(
config: &RuntimeHostConfig,
session: Session,
session_id: &str,
+ auth: AuthSource,
) -> Result {
let runtime_plugin_state = build_runtime_plugin_state(&config.cwd)?;
let RuntimePluginState {
@@ -593,10 +610,11 @@ fn build_runtime(
let runtime = ConversationRuntime::new_with_features(
session,
AnthropicBridgeClient::new(
- &config.cwd,
session_id,
+ auth.clone(),
config.model.clone(),
config.allowed_tools.clone(),
+ tool_registry.clone(),
)?,
BridgeToolExecutor::new(
config.allowed_tools.clone(),
@@ -604,7 +622,7 @@ fn build_runtime(
mcp_state.clone(),
),
policy,
- build_system_prompt(&config.cwd)?,
+ build_system_prompt(&config.cwd, &auth)?,
&feature_config,
);
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
@@ -833,16 +851,15 @@ struct AnthropicBridgeClient {
impl AnthropicBridgeClient {
fn new(
- cwd: &Path,
session_id: &str,
+ auth: AuthSource,
model: String,
allowed_tools: Option,
+ tool_registry: GlobalToolRegistry,
) -> Result {
- let runtime_plugin_state = build_runtime_plugin_state(cwd)?;
- let tool_registry = runtime_plugin_state.tool_registry.clone();
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(HostError::Io)?,
- client: AnthropicClient::from_auth(resolve_cli_auth_source(cwd)?)
+ client: AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id)),
model,
@@ -852,6 +869,30 @@ impl AnthropicBridgeClient {
}
}
+#[cfg(test)]
+mod oauth_tests {
+ use super::{with_claude_code_oauth_prefix, AuthSource, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX};
+
+ #[test]
+ fn oauth_system_prefix_is_prepended_for_claude_code_tokens() {
+ let system_prompt = with_claude_code_oauth_prefix(
+ vec!["existing prompt".to_string()],
+ &AuthSource::BearerToken("sk-ant-oat01-test".to_string()),
+ );
+ assert_eq!(system_prompt[0], CLAUDE_CODE_OAUTH_SYSTEM_PREFIX);
+ assert_eq!(system_prompt[1], "existing prompt");
+ }
+
+ #[test]
+ fn oauth_system_prefix_is_not_duplicated() {
+ let system_prompt = with_claude_code_oauth_prefix(
+ vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()],
+ &AuthSource::BearerToken("sk-ant-oat01-test".to_string()),
+ );
+ assert_eq!(system_prompt, vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()]);
+ }
+}
+
fn resolve_cli_auth_source(cwd: &Path) -> Result {
resolve_startup_auth_source(|| {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
diff --git a/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml b/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml
index e3d0311..75993d3 100644
--- a/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml
+++ b/rust/crates/claw-telegram/assets/unraid/claw-telegram-gateway.xml
@@ -33,9 +33,9 @@
/unraid/templates-user
/appdata/template-archive
claw_gateway
- ANTHROPIC_API_KEY
+ ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY
15
-
-
+
+
diff --git a/rust/crates/claw-telegram/src/runtime_host.rs b/rust/crates/claw-telegram/src/runtime_host.rs
index c215627..b60f836 100644
--- a/rust/crates/claw-telegram/src/runtime_host.rs
+++ b/rust/crates/claw-telegram/src/runtime_host.rs
@@ -29,6 +29,8 @@ use tools::{GlobalToolRegistry, RuntimeToolDefinition};
const DEFAULT_PROMPT_DATE: &str = "2026-03-31";
const APPROVAL_POLL_INTERVAL: Duration = Duration::from_millis(250);
+const CLAUDE_CODE_OAUTH_SYSTEM_PREFIX: &str =
+ "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
pub type AllowedToolSet = BTreeSet;
@@ -173,7 +175,8 @@ impl RuntimeHost {
event_tx: UnboundedSender,
) -> Result<(), HostError> {
let session = self.load_or_create_session(session_path)?;
- let system_prompt = build_system_prompt(&self.config.cwd)?;
+ let auth = resolve_cli_auth_source(&self.config.cwd)?;
+ let system_prompt = build_system_prompt(&self.config.cwd, &auth)?;
let mut runtime = build_runtime(
&self.config,
session,
@@ -181,6 +184,7 @@ impl RuntimeHost {
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("telegram-session"),
+ auth,
)?;
let mut observer = BridgeObserver::new(event_tx.clone());
let mut prompter = BridgePermissionPrompter::new(
@@ -261,9 +265,21 @@ impl From for HostError {
}
}
-fn build_system_prompt(cwd: &Path) -> Result, HostError> {
- load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown")
- .map_err(|error| HostError::Other(error.to_string()))
+fn build_system_prompt(cwd: &Path, auth: &AuthSource) -> Result, HostError> {
+ let system_prompt = load_system_prompt(cwd, DEFAULT_PROMPT_DATE, std::env::consts::OS, "unknown")
+ .map_err(|error| HostError::Other(error.to_string()))?;
+ Ok(with_claude_code_oauth_prefix(system_prompt, auth))
+}
+
+fn with_claude_code_oauth_prefix(mut system_prompt: Vec, auth: &AuthSource) -> Vec {
+ if auth.uses_claude_code_oauth()
+ && !system_prompt
+ .iter()
+ .any(|segment| segment.contains(CLAUDE_CODE_OAUTH_SYSTEM_PREFIX))
+ {
+ system_prompt.insert(0, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string());
+ }
+ system_prompt
}
#[derive(Debug, Clone)]
@@ -575,6 +591,7 @@ fn build_runtime(
config: &RuntimeHostConfig,
session: Session,
session_id: &str,
+ auth: AuthSource,
) -> Result {
let runtime_plugin_state = build_runtime_plugin_state(&config.cwd)?;
let RuntimePluginState {
@@ -591,10 +608,11 @@ fn build_runtime(
let runtime = ConversationRuntime::new_with_features(
session,
AnthropicBridgeClient::new(
- &config.cwd,
session_id,
+ auth.clone(),
config.model.clone(),
config.allowed_tools.clone(),
+ tool_registry.clone(),
)?,
BridgeToolExecutor::new(
config.allowed_tools.clone(),
@@ -602,7 +620,7 @@ fn build_runtime(
mcp_state.clone(),
),
policy,
- build_system_prompt(&config.cwd)?,
+ build_system_prompt(&config.cwd, &auth)?,
&feature_config,
);
Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state))
@@ -831,16 +849,15 @@ struct AnthropicBridgeClient {
impl AnthropicBridgeClient {
fn new(
- cwd: &Path,
session_id: &str,
+ auth: AuthSource,
model: String,
allowed_tools: Option,
+ tool_registry: GlobalToolRegistry,
) -> Result {
- let runtime_plugin_state = build_runtime_plugin_state(cwd)?;
- let tool_registry = runtime_plugin_state.tool_registry.clone();
Ok(Self {
runtime: tokio::runtime::Runtime::new().map_err(HostError::Io)?,
- client: AnthropicClient::from_auth(resolve_cli_auth_source(cwd)?)
+ client: AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id)),
model,
@@ -850,6 +867,30 @@ impl AnthropicBridgeClient {
}
}
+#[cfg(test)]
+mod oauth_tests {
+ use super::{with_claude_code_oauth_prefix, AuthSource, CLAUDE_CODE_OAUTH_SYSTEM_PREFIX};
+
+ #[test]
+ fn oauth_system_prefix_is_prepended_for_claude_code_tokens() {
+ let system_prompt = with_claude_code_oauth_prefix(
+ vec!["existing prompt".to_string()],
+ &AuthSource::BearerToken("sk-ant-oat01-test".to_string()),
+ );
+ assert_eq!(system_prompt[0], CLAUDE_CODE_OAUTH_SYSTEM_PREFIX);
+ assert_eq!(system_prompt[1], "existing prompt");
+ }
+
+ #[test]
+ fn oauth_system_prefix_is_not_duplicated() {
+ let system_prompt = with_claude_code_oauth_prefix(
+ vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()],
+ &AuthSource::BearerToken("sk-ant-oat01-test".to_string()),
+ );
+ assert_eq!(system_prompt, vec![CLAUDE_CODE_OAUTH_SYSTEM_PREFIX.to_string()]);
+ }
+}
+
fn resolve_cli_auth_source(cwd: &Path) -> Result {
resolve_startup_auth_source(|| {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
diff --git a/rust/crates/claw-telegram/src/unraid_template_manager.rs b/rust/crates/claw-telegram/src/unraid_template_manager.rs
index 98dc1eb..3e106aa 100644
--- a/rust/crates/claw-telegram/src/unraid_template_manager.rs
+++ b/rust/crates/claw-telegram/src/unraid_template_manager.rs
@@ -115,7 +115,7 @@ impl UnraidTemplateManager {
repository: &str,
) -> Result<(), TemplateError> {
let xml = format!(
- "\n\n claw-telegram-gateway\n {repository}\n \n claw_gateway\n \n sh\n false\n https://git.wylab.me/wylab/claw-code-parity\n claw-code-parity\n Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.\n AI:Tools\n \n \n \n --entrypoint claw-telegram\n gateway serve\n \n {}\n \n \n \n /var/run/docker.sock\n /boot/config/plugins/dockerMan/templates-user\n /mnt/user/appdata/claw-telegram-gateway\n \n \n {repository}\n /appdata/state\n /appdata/profiles.json\n /var/run/docker.sock\n /unraid/templates-user\n /appdata/template-archive\n claw_gateway\n ANTHROPIC_API_KEY\n 15\n \n \n \n\n",
+ "\n\n claw-telegram-gateway\n {repository}\n \n claw_gateway\n \n sh\n false\n https://git.wylab.me/wylab/claw-code-parity\n claw-code-parity\n Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.\n AI:Tools\n \n \n \n --entrypoint claw-telegram\n gateway serve\n \n {}\n \n \n \n /var/run/docker.sock\n /boot/config/plugins/dockerMan/templates-user\n /mnt/user/appdata/claw-telegram-gateway\n \n \n {repository}\n /appdata/state\n /appdata/profiles.json\n /var/run/docker.sock\n /unraid/templates-user\n /appdata/template-archive\n claw_gateway\n ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY\n 15\n \n \n \n\n",
current_timestamp_secs()
);
let destination = destination.as_ref();