From 344e2ac7d14241ed49f747ddd318aedefb731d5d Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 9 Mar 2026 22:22:42 +0700 Subject: [PATCH] feat(i18n): add full i18n support for backend and web UI - Add i18next + react-i18next with namespace-split locale files (27 namespaces x 3 languages) - Add language switcher in topbar (EN/VI/ZH) with localStorage persistence - Replace hardcoded strings in 160+ React components with t() translations - Add Go message catalog (internal/i18n) with T(locale, key, args...) function - Replace 81 hardcoded error strings in gateway methods and HTTP handlers - Add locale context propagation: WS connect param + HTTP Accept-Language header - Keep technical terms in English: Agent, Session, Channel, Provider, Skill, Team, MCP, Cron - Update CLAUDE.md and review-pr skill with i18n compliance checks --- CLAUDE.md | 5 +- internal/gateway/client.go | 2 + internal/gateway/methods/agent_links.go | 64 ++-- internal/gateway/methods/agents.go | 6 +- internal/gateway/methods/agents_create.go | 16 +- internal/gateway/methods/agents_delete.go | 17 +- internal/gateway/methods/agents_files.go | 32 +- internal/gateway/methods/agents_update.go | 15 +- internal/gateway/methods/channel_instances.go | 34 +- internal/gateway/methods/channels.go | 7 +- internal/gateway/methods/chat.go | 27 +- internal/gateway/methods/config.go | 23 +- internal/gateway/methods/cron.go | 48 +-- internal/gateway/methods/delegations.go | 17 +- internal/gateway/methods/exec_approval.go | 20 +- internal/gateway/methods/logs.go | 7 +- internal/gateway/methods/pairing.go | 24 +- internal/gateway/methods/send.go | 11 +- internal/gateway/methods/sessions.go | 23 +- internal/gateway/methods/skills.go | 23 +- internal/gateway/methods/teams.go | 24 +- internal/gateway/methods/teams_crud.go | 71 ++-- internal/gateway/methods/teams_members.go | 41 +-- internal/gateway/router.go | 15 +- internal/http/agents.go | 50 +-- internal/http/agents_instances.go | 47 +-- internal/http/agents_sharing.go | 54 +-- internal/http/auth.go | 19 + internal/http/builtin_tools.go | 19 +- internal/http/channel_instances.go | 87 +++-- internal/http/chat_completions.go | 29 +- internal/http/custom_tools.go | 38 +- internal/http/delegations.go | 10 +- internal/http/files.go | 10 +- internal/http/knowledge_graph_handlers.go | 23 +- internal/http/mcp.go | 35 +- internal/http/mcp_grants.go | 31 +- internal/http/mcp_requests.go | 13 +- internal/http/mcp_tools.go | 12 +- internal/http/media_serve.go | 7 +- internal/http/media_upload.go | 15 +- internal/http/memory.go | 4 +- internal/http/memory_handlers.go | 20 +- internal/http/oauth.go | 14 +- internal/http/pending_messages.go | 18 +- internal/http/provider_models.go | 8 +- internal/http/provider_verify.go | 10 +- internal/http/providers.go | 35 +- internal/http/skills.go | 21 +- internal/http/skills_grants.go | 28 +- internal/http/skills_upload.go | 24 +- internal/http/skills_versions.go | 38 +- internal/http/storage.go | 40 +- internal/http/tools_invoke.go | 14 +- internal/http/traces.go | 12 +- internal/i18n/catalog_en.go | 132 +++++++ internal/i18n/catalog_vi.go | 132 +++++++ internal/i18n/catalog_zh.go | 132 +++++++ internal/i18n/i18n.go | 80 ++++ internal/i18n/keys.go | 132 +++++++ internal/store/context.go | 15 + ui/web/package.json | 2 + ui/web/pnpm-lock.yaml | 66 ++++ ui/web/src/api/ws-client.ts | 1 + ui/web/src/components/chat/agent-selector.tsx | 8 +- ui/web/src/components/chat/chat-input.tsx | 8 +- ui/web/src/components/chat/rich-content.tsx | 21 +- .../src/components/chat/session-switcher.tsx | 6 +- ui/web/src/components/chat/thinking-block.tsx | 4 +- ui/web/src/components/chat/tool-call-card.tsx | 11 +- ui/web/src/components/layout/app-layout.tsx | 4 +- .../components/layout/connection-status.tsx | 4 +- ui/web/src/components/layout/sidebar.tsx | 58 +-- ui/web/src/components/layout/topbar.tsx | 31 +- .../shared/confirm-delete-dialog.tsx | 10 +- .../src/components/shared/confirm-dialog.tsx | 8 +- ui/web/src/components/shared/file-browser.tsx | 4 +- ui/web/src/components/shared/file-tree.tsx | 4 +- ui/web/src/components/shared/file-viewers.tsx | 4 +- ui/web/src/components/shared/pagination.tsx | 8 +- .../shared/provider-model-select.tsx | 28 +- ui/web/src/components/shared/search-input.tsx | 6 +- ui/web/src/components/shared/tag-input.tsx | 6 +- .../components/shared/tool-name-select.tsx | 10 +- ui/web/src/i18n/index.ts | 161 +++++++++ ui/web/src/i18n/locales/en/agents.json | 342 ++++++++++++++++++ ui/web/src/i18n/locales/en/approvals.json | 23 ++ ui/web/src/i18n/locales/en/channels.json | 213 +++++++++++ ui/web/src/i18n/locales/en/chat.json | 20 + ui/web/src/i18n/locales/en/common.json | 104 ++++++ ui/web/src/i18n/locales/en/cron.json | 102 ++++++ ui/web/src/i18n/locales/en/delegations.json | 41 +++ ui/web/src/i18n/locales/en/events.json | 34 ++ ui/web/src/i18n/locales/en/login.json | 32 ++ ui/web/src/i18n/locales/en/logs.json | 15 + ui/web/src/i18n/locales/en/mcp.json | 99 +++++ ui/web/src/i18n/locales/en/memory.json | 166 +++++++++ ui/web/src/i18n/locales/en/nodes.json | 35 ++ ui/web/src/i18n/locales/en/overview.json | 74 ++++ .../src/i18n/locales/en/pending-messages.json | 31 ++ ui/web/src/i18n/locales/en/providers.json | 115 ++++++ ui/web/src/i18n/locales/en/sessions.json | 34 ++ ui/web/src/i18n/locales/en/setup.json | 102 ++++++ ui/web/src/i18n/locales/en/sidebar.json | 34 ++ ui/web/src/i18n/locales/en/skills.json | 55 +++ ui/web/src/i18n/locales/en/storage.json | 14 + ui/web/src/i18n/locales/en/teams.json | 150 ++++++++ ui/web/src/i18n/locales/en/tools.json | 156 ++++++++ ui/web/src/i18n/locales/en/topbar.json | 16 + ui/web/src/i18n/locales/en/traces.json | 49 +++ ui/web/src/i18n/locales/en/tts.json | 71 ++++ ui/web/src/i18n/locales/en/usage.json | 21 ++ ui/web/src/i18n/locales/vi/agents.json | 342 ++++++++++++++++++ ui/web/src/i18n/locales/vi/approvals.json | 23 ++ ui/web/src/i18n/locales/vi/channels.json | 213 +++++++++++ ui/web/src/i18n/locales/vi/chat.json | 20 + ui/web/src/i18n/locales/vi/common.json | 104 ++++++ ui/web/src/i18n/locales/vi/cron.json | 102 ++++++ ui/web/src/i18n/locales/vi/delegations.json | 41 +++ ui/web/src/i18n/locales/vi/events.json | 34 ++ ui/web/src/i18n/locales/vi/login.json | 32 ++ ui/web/src/i18n/locales/vi/logs.json | 15 + ui/web/src/i18n/locales/vi/mcp.json | 99 +++++ ui/web/src/i18n/locales/vi/memory.json | 166 +++++++++ ui/web/src/i18n/locales/vi/nodes.json | 35 ++ ui/web/src/i18n/locales/vi/overview.json | 74 ++++ .../src/i18n/locales/vi/pending-messages.json | 31 ++ ui/web/src/i18n/locales/vi/providers.json | 115 ++++++ ui/web/src/i18n/locales/vi/sessions.json | 34 ++ ui/web/src/i18n/locales/vi/setup.json | 102 ++++++ ui/web/src/i18n/locales/vi/sidebar.json | 34 ++ ui/web/src/i18n/locales/vi/skills.json | 55 +++ ui/web/src/i18n/locales/vi/storage.json | 14 + ui/web/src/i18n/locales/vi/teams.json | 150 ++++++++ ui/web/src/i18n/locales/vi/tools.json | 156 ++++++++ ui/web/src/i18n/locales/vi/topbar.json | 16 + ui/web/src/i18n/locales/vi/traces.json | 49 +++ ui/web/src/i18n/locales/vi/tts.json | 71 ++++ ui/web/src/i18n/locales/vi/usage.json | 21 ++ ui/web/src/i18n/locales/zh/agents.json | 342 ++++++++++++++++++ ui/web/src/i18n/locales/zh/approvals.json | 23 ++ ui/web/src/i18n/locales/zh/channels.json | 213 +++++++++++ ui/web/src/i18n/locales/zh/chat.json | 20 + ui/web/src/i18n/locales/zh/common.json | 104 ++++++ ui/web/src/i18n/locales/zh/cron.json | 102 ++++++ ui/web/src/i18n/locales/zh/delegations.json | 41 +++ ui/web/src/i18n/locales/zh/events.json | 34 ++ ui/web/src/i18n/locales/zh/login.json | 32 ++ ui/web/src/i18n/locales/zh/logs.json | 15 + ui/web/src/i18n/locales/zh/mcp.json | 99 +++++ ui/web/src/i18n/locales/zh/memory.json | 166 +++++++++ ui/web/src/i18n/locales/zh/nodes.json | 35 ++ ui/web/src/i18n/locales/zh/overview.json | 74 ++++ .../src/i18n/locales/zh/pending-messages.json | 31 ++ ui/web/src/i18n/locales/zh/providers.json | 115 ++++++ ui/web/src/i18n/locales/zh/sessions.json | 34 ++ ui/web/src/i18n/locales/zh/setup.json | 102 ++++++ ui/web/src/i18n/locales/zh/sidebar.json | 34 ++ ui/web/src/i18n/locales/zh/skills.json | 55 +++ ui/web/src/i18n/locales/zh/storage.json | 14 + ui/web/src/i18n/locales/zh/teams.json | 150 ++++++++ ui/web/src/i18n/locales/zh/tools.json | 156 ++++++++ ui/web/src/i18n/locales/zh/topbar.json | 16 + ui/web/src/i18n/locales/zh/traces.json | 49 +++ ui/web/src/i18n/locales/zh/tts.json | 71 ++++ ui/web/src/i18n/locales/zh/usage.json | 21 ++ ui/web/src/lib/constants.ts | 10 + ui/web/src/main.tsx | 1 + ui/web/src/pages/agents/agent-card.tsx | 22 +- .../src/pages/agents/agent-create-dialog.tsx | 59 +-- .../agents/agent-detail/agent-config-tab.tsx | 11 +- .../agents/agent-detail/agent-danger-tab.tsx | 17 +- .../agents/agent-detail/agent-detail-page.tsx | 43 ++- .../agents/agent-detail/agent-files-tab.tsx | 23 +- .../agents/agent-detail/agent-general-tab.tsx | 18 +- .../agent-detail/agent-instances-tab.tsx | 23 +- .../agents/agent-detail/agent-links-tab.tsx | 15 +- .../agents/agent-detail/agent-shares-tab.tsx | 28 +- .../agents/agent-detail/agent-skills-tab.tsx | 20 +- .../config-sections/compaction-section.tsx | 19 +- .../config-sections/config-section.tsx | 4 +- .../context-pruning-section.tsx | 31 +- .../config-sections/memory-section.tsx | 21 +- .../config-sections/other-config-section.tsx | 11 +- .../config-sections/quality-gates-section.tsx | 26 +- .../config-sections/sandbox-section.tsx | 23 +- .../config-sections/subagents-section.tsx | 25 +- .../config-sections/thinking-section.tsx | 26 +- .../config-sections/tool-policy-section.tsx | 21 +- .../file-sections/file-editor.tsx | 9 +- .../file-sections/file-sidebar.tsx | 10 +- .../file-sections/open-agent-empty-state.tsx | 22 +- .../file-sections/regenerate-dialog.tsx | 13 +- .../general-sections/identity-section.tsx | 30 +- .../general-sections/llm-config-section.tsx | 12 +- .../general-sections/workspace-section.tsx | 15 +- .../link-sections/link-create-form.tsx | 32 +- .../link-sections/link-edit-dialog.tsx | 26 +- .../agent-detail/link-sections/link-list.tsx | 17 +- ui/web/src/pages/agents/agents-page.tsx | 22 +- ui/web/src/pages/agents/summoning-modal.tsx | 26 +- ui/web/src/pages/approvals/approvals-page.tsx | 35 +- .../builtin-tool-settings-dialog.tsx | 18 +- .../builtin-tools/builtin-tools-page.tsx | 53 ++- .../pages/builtin-tools/kg-settings-form.tsx | 26 +- .../media-provider-chain-form.tsx | 31 +- .../channel-detail/channel-config-tab.tsx | 8 +- .../channel-credentials-tab.tsx | 14 +- .../channel-detail/channel-detail-page.tsx | 18 +- .../channel-detail/channel-general-tab.tsx | 22 +- .../channel-detail/channel-groups-tab.tsx | 6 +- .../channel-detail/channel-writers-tab.tsx | 66 ++-- ui/web/src/pages/channels/channel-fields.tsx | 6 +- .../channels/channel-instance-form-dialog.tsx | 69 ++-- ui/web/src/pages/channels/channels-page.tsx | 40 +- .../pages/channels/channels-status-view.tsx | 18 +- .../channels/hooks/use-channel-instances.ts | 16 +- .../pages/channels/telegram-group-fields.tsx | 53 +-- .../channels/telegram-group-overrides.tsx | 16 +- .../channels/telegram-topic-overrides.tsx | 10 +- .../channels/zalo/zalo-contacts-picker.tsx | 26 +- .../channels/zalo/zalo-personal-qr-dialog.tsx | 14 +- .../pages/channels/zalo/zalo-wizard-steps.tsx | 12 +- ui/web/src/pages/chat/chat-page.tsx | 6 +- ui/web/src/pages/chat/chat-sidebar.tsx | 4 +- ui/web/src/pages/chat/chat-thread.tsx | 6 +- ui/web/src/pages/config/config-page.tsx | 36 +- .../sections/agents-defaults-section.tsx | 90 ++--- .../config/sections/bindings-section.tsx | 20 +- .../config/sections/channels-section.tsx | 34 +- .../pages/config/sections/cron-section.tsx | 14 +- .../pages/config/sections/gateway-section.tsx | 34 +- .../config/sections/providers-section.tsx | 18 +- .../pages/config/sections/quota-section.tsx | 59 +-- .../config/sections/sessions-section.tsx | 12 +- .../config/sections/telemetry-section.tsx | 26 +- .../pages/config/sections/tools-section.tsx | 56 +-- .../src/pages/config/sections/tts-section.tsx | 13 +- ui/web/src/pages/cron/cron-detail-page.tsx | 60 +-- ui/web/src/pages/cron/cron-form-dialog.tsx | 34 +- ui/web/src/pages/cron/cron-page.tsx | 51 +-- ui/web/src/pages/cron/cron-run-log-dialog.tsx | 7 +- ui/web/src/pages/cron/hooks/use-cron.ts | 17 +- .../custom-tools/custom-tool-form-dialog.tsx | 44 +-- .../pages/custom-tools/custom-tools-page.tsx | 43 ++- .../custom-tools/hooks/use-custom-tools.ts | 13 +- .../delegations/delegation-detail-dialog.tsx | 30 +- .../pages/delegations/delegations-page.tsx | 43 ++- .../event-sections/event-detail-dialog.tsx | 4 +- ui/web/src/pages/events/events-page.tsx | 62 ++-- ui/web/src/pages/login/login-page.tsx | 4 +- ui/web/src/pages/login/login-tabs.tsx | 7 +- ui/web/src/pages/login/pairing-form.tsx | 26 +- ui/web/src/pages/login/token-form.tsx | 18 +- ui/web/src/pages/logs/logs-page.tsx | 28 +- ui/web/src/pages/mcp/hooks/use-mcp.ts | 13 +- ui/web/src/pages/mcp/mcp-form-dialog.tsx | 78 ++-- ui/web/src/pages/mcp/mcp-grants-dialog.tsx | 32 +- ui/web/src/pages/mcp/mcp-page.tsx | 41 ++- ui/web/src/pages/mcp/mcp-tools-dialog.tsx | 18 +- ui/web/src/pages/memory/kg-entities-tab.tsx | 36 +- .../pages/memory/kg-entity-detail-dialog.tsx | 48 ++- ui/web/src/pages/memory/kg-extract-dialog.tsx | 16 +- ui/web/src/pages/memory/kg-graph-view.tsx | 4 +- .../src/pages/memory/memory-create-dialog.tsx | 43 ++- .../pages/memory/memory-document-dialog.tsx | 38 +- ui/web/src/pages/memory/memory-page.tsx | 51 +-- .../src/pages/memory/memory-search-dialog.tsx | 14 +- ui/web/src/pages/nodes/nodes-page.tsx | 54 +-- .../pages/overview/connected-clients-card.tsx | 16 +- ui/web/src/pages/overview/cron-jobs-card.tsx | 10 +- ui/web/src/pages/overview/overview-page.tsx | 34 +- .../src/pages/overview/quota-usage-card.tsx | 17 +- .../pages/overview/recent-requests-card.tsx | 22 +- .../src/pages/overview/system-health-card.tsx | 24 +- .../pending-messages/message-list-dialog.tsx | 8 +- .../pending-messages-page.tsx | 45 +-- .../pages/providers/hooks/use-providers.ts | 16 +- .../pages/providers/provider-cli-section.tsx | 20 +- .../pages/providers/provider-form-dialog.tsx | 72 ++-- .../providers/provider-oauth-section.tsx | 46 ++- ui/web/src/pages/providers/providers-page.tsx | 41 ++- .../pages/sessions/session-detail-page.tsx | 32 +- ui/web/src/pages/sessions/sessions-page.tsx | 24 +- .../src/pages/setup/setup-complete-modal.tsx | 10 +- ui/web/src/pages/setup/setup-layout.tsx | 5 +- ui/web/src/pages/setup/setup-stepper.tsx | 17 +- ui/web/src/pages/setup/step-agent.tsx | 40 +- ui/web/src/pages/setup/step-channel.tsx | 40 +- ui/web/src/pages/setup/step-model.tsx | 26 +- ui/web/src/pages/setup/step-provider.tsx | 30 +- .../src/pages/skills/skill-detail-dialog.tsx | 12 +- ui/web/src/pages/skills/skill-edit-dialog.tsx | 34 +- .../src/pages/skills/skill-upload-dialog.tsx | 16 +- ui/web/src/pages/skills/skills-page.tsx | 34 +- ui/web/src/pages/storage/storage-page.tsx | 20 +- .../teams/member-sections/member-list.tsx | 13 +- .../task-sections/task-detail-dialog.tsx | 23 +- .../pages/teams/task-sections/task-list.tsx | 16 +- ui/web/src/pages/teams/team-card.tsx | 4 +- ui/web/src/pages/teams/team-create-dialog.tsx | 30 +- .../src/pages/teams/team-delegations-tab.tsx | 18 +- ui/web/src/pages/teams/team-detail-page.tsx | 24 +- ui/web/src/pages/teams/team-members-tab.tsx | 12 +- ui/web/src/pages/teams/team-settings-tab.tsx | 46 +-- ui/web/src/pages/teams/team-tasks-tab.tsx | 4 +- ui/web/src/pages/teams/teams-page.tsx | 25 +- .../src/pages/traces/trace-detail-dialog.tsx | 59 +-- ui/web/src/pages/traces/traces-page.tsx | 33 +- ui/web/src/pages/tts/tts-page.tsx | 129 +++---- ui/web/src/pages/usage/usage-page.tsx | 34 +- ui/web/src/stores/use-ui-store.ts | 11 +- 312 files changed, 10361 insertions(+), 2441 deletions(-) create mode 100644 internal/i18n/catalog_en.go create mode 100644 internal/i18n/catalog_vi.go create mode 100644 internal/i18n/catalog_zh.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/keys.go create mode 100644 ui/web/src/i18n/index.ts create mode 100644 ui/web/src/i18n/locales/en/agents.json create mode 100644 ui/web/src/i18n/locales/en/approvals.json create mode 100644 ui/web/src/i18n/locales/en/channels.json create mode 100644 ui/web/src/i18n/locales/en/chat.json create mode 100644 ui/web/src/i18n/locales/en/common.json create mode 100644 ui/web/src/i18n/locales/en/cron.json create mode 100644 ui/web/src/i18n/locales/en/delegations.json create mode 100644 ui/web/src/i18n/locales/en/events.json create mode 100644 ui/web/src/i18n/locales/en/login.json create mode 100644 ui/web/src/i18n/locales/en/logs.json create mode 100644 ui/web/src/i18n/locales/en/mcp.json create mode 100644 ui/web/src/i18n/locales/en/memory.json create mode 100644 ui/web/src/i18n/locales/en/nodes.json create mode 100644 ui/web/src/i18n/locales/en/overview.json create mode 100644 ui/web/src/i18n/locales/en/pending-messages.json create mode 100644 ui/web/src/i18n/locales/en/providers.json create mode 100644 ui/web/src/i18n/locales/en/sessions.json create mode 100644 ui/web/src/i18n/locales/en/setup.json create mode 100644 ui/web/src/i18n/locales/en/sidebar.json create mode 100644 ui/web/src/i18n/locales/en/skills.json create mode 100644 ui/web/src/i18n/locales/en/storage.json create mode 100644 ui/web/src/i18n/locales/en/teams.json create mode 100644 ui/web/src/i18n/locales/en/tools.json create mode 100644 ui/web/src/i18n/locales/en/topbar.json create mode 100644 ui/web/src/i18n/locales/en/traces.json create mode 100644 ui/web/src/i18n/locales/en/tts.json create mode 100644 ui/web/src/i18n/locales/en/usage.json create mode 100644 ui/web/src/i18n/locales/vi/agents.json create mode 100644 ui/web/src/i18n/locales/vi/approvals.json create mode 100644 ui/web/src/i18n/locales/vi/channels.json create mode 100644 ui/web/src/i18n/locales/vi/chat.json create mode 100644 ui/web/src/i18n/locales/vi/common.json create mode 100644 ui/web/src/i18n/locales/vi/cron.json create mode 100644 ui/web/src/i18n/locales/vi/delegations.json create mode 100644 ui/web/src/i18n/locales/vi/events.json create mode 100644 ui/web/src/i18n/locales/vi/login.json create mode 100644 ui/web/src/i18n/locales/vi/logs.json create mode 100644 ui/web/src/i18n/locales/vi/mcp.json create mode 100644 ui/web/src/i18n/locales/vi/memory.json create mode 100644 ui/web/src/i18n/locales/vi/nodes.json create mode 100644 ui/web/src/i18n/locales/vi/overview.json create mode 100644 ui/web/src/i18n/locales/vi/pending-messages.json create mode 100644 ui/web/src/i18n/locales/vi/providers.json create mode 100644 ui/web/src/i18n/locales/vi/sessions.json create mode 100644 ui/web/src/i18n/locales/vi/setup.json create mode 100644 ui/web/src/i18n/locales/vi/sidebar.json create mode 100644 ui/web/src/i18n/locales/vi/skills.json create mode 100644 ui/web/src/i18n/locales/vi/storage.json create mode 100644 ui/web/src/i18n/locales/vi/teams.json create mode 100644 ui/web/src/i18n/locales/vi/tools.json create mode 100644 ui/web/src/i18n/locales/vi/topbar.json create mode 100644 ui/web/src/i18n/locales/vi/traces.json create mode 100644 ui/web/src/i18n/locales/vi/tts.json create mode 100644 ui/web/src/i18n/locales/vi/usage.json create mode 100644 ui/web/src/i18n/locales/zh/agents.json create mode 100644 ui/web/src/i18n/locales/zh/approvals.json create mode 100644 ui/web/src/i18n/locales/zh/channels.json create mode 100644 ui/web/src/i18n/locales/zh/chat.json create mode 100644 ui/web/src/i18n/locales/zh/common.json create mode 100644 ui/web/src/i18n/locales/zh/cron.json create mode 100644 ui/web/src/i18n/locales/zh/delegations.json create mode 100644 ui/web/src/i18n/locales/zh/events.json create mode 100644 ui/web/src/i18n/locales/zh/login.json create mode 100644 ui/web/src/i18n/locales/zh/logs.json create mode 100644 ui/web/src/i18n/locales/zh/mcp.json create mode 100644 ui/web/src/i18n/locales/zh/memory.json create mode 100644 ui/web/src/i18n/locales/zh/nodes.json create mode 100644 ui/web/src/i18n/locales/zh/overview.json create mode 100644 ui/web/src/i18n/locales/zh/pending-messages.json create mode 100644 ui/web/src/i18n/locales/zh/providers.json create mode 100644 ui/web/src/i18n/locales/zh/sessions.json create mode 100644 ui/web/src/i18n/locales/zh/setup.json create mode 100644 ui/web/src/i18n/locales/zh/sidebar.json create mode 100644 ui/web/src/i18n/locales/zh/skills.json create mode 100644 ui/web/src/i18n/locales/zh/storage.json create mode 100644 ui/web/src/i18n/locales/zh/teams.json create mode 100644 ui/web/src/i18n/locales/zh/tools.json create mode 100644 ui/web/src/i18n/locales/zh/topbar.json create mode 100644 ui/web/src/i18n/locales/zh/traces.json create mode 100644 ui/web/src/i18n/locales/zh/tts.json create mode 100644 ui/web/src/i18n/locales/zh/usage.json diff --git a/CLAUDE.md b/CLAUDE.md index 7b5a54d2..acfc1b69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ internal/ ├── crypto/ AES-256-GCM encryption for API keys ├── sandbox/ Docker-based code sandbox ├── tts/ Text-to-Speech (OpenAI, ElevenLabs, Edge, MiniMax) +├── i18n/ Message catalog: T(locale, key, args...) + per-locale catalogs (en/vi/zh) pkg/protocol/ Wire types (frames, methods, errors, events) pkg/browser/ Browser automation (Rod + CDP) migrations/ PostgreSQL migration files @@ -46,11 +47,12 @@ ui/web/ React SPA (pnpm, Vite, Tailwind, Radix UI) - **Context files:** `agent_context_files` (agent-level) + `user_context_files` (per-user), routed via `ContextFileInterceptor` - **Providers:** Anthropic (native HTTP+SSE) and OpenAI-compat (generic). Both use `RetryDo()` for retries. Loads from `llm_providers` table with encrypted API keys - **Agent loop:** `RunRequest` → think→act→observe → `RunResult`. Events: `run.started`, `run.completed`, `chunk`, `tool.call`, `tool.result`. Auto-summarization at >75% context -- **Context propagation:** `store.WithAgentType(ctx)`, `store.WithUserID(ctx)`, `store.WithAgentID(ctx)` +- **Context propagation:** `store.WithAgentType(ctx)`, `store.WithUserID(ctx)`, `store.WithAgentID(ctx)`, `store.WithLocale(ctx)` - **WebSocket protocol (v3):** Frame types `req`/`res`/`event`. First request must be `connect` - **Config:** JSON5 at `GOCLAW_CONFIG` env. Secrets in `.env.local` or env vars, never in config.json - **Security:** Rate limiting, input guard (detection-only), CORS, shell deny patterns, SSRF protection, path traversal prevention, AES-256-GCM encryption. All security logs: `slog.Warn("security.*")` - **Telegram formatting:** LLM output → `SanitizeAssistantContent()` → `markdownToTelegramHTML()` → `chunkHTML()` → `sendHTML()`. Tables rendered as ASCII in `
` tags
+- **i18n:** Web UI uses `i18next` with namespace-split locale files in `ui/web/src/i18n/locales/{lang}/`. Backend uses `internal/i18n` message catalog with `i18n.T(locale, key, args...)`. Locale propagated via `store.WithLocale(ctx)` — WS `connect` param `locale`, HTTP `Accept-Language` header. Supported: en (default), vi, zh. New user-facing strings: add key to `internal/i18n/keys.go`, add translations to all 3 catalog files. New UI strings: add key to all 3 locale dirs. Bootstrap templates (SOUL.md, etc.) stay English-only (LLM consumption).
 
 ## Running
 
@@ -78,3 +80,4 @@ Go conventions to follow:
 - Use `append(dst, src...)` instead of loop-based append
 - Always handle errors; don't ignore return values
 - **Migrations:** When adding a new SQL migration file in `migrations/`, bump `RequiredSchemaVersion` in `internal/upgrade/version.go` to match the new migration number
+- **i18n strings:** When adding user-facing error messages, add key to `internal/i18n/keys.go` and translations to `catalog_en.go`, `catalog_vi.go`, `catalog_zh.go`. For UI strings, add to all locale JSON files in `ui/web/src/i18n/locales/{en,vi,zh}/`
diff --git a/internal/gateway/client.go b/internal/gateway/client.go
index a22a9ab4..ce5b7457 100644
--- a/internal/gateway/client.go
+++ b/internal/gateway/client.go
@@ -26,6 +26,8 @@ type Client struct {
 	connectedAt time.Time // when the client connected
 	remoteAddr  string    // peer IP (extracted from proxy headers or RemoteAddr)
 
+	locale string // user's preferred locale (e.g. "en", "vi", "zh")
+
 	// Browser pairing state
 	pairingCode     string // 8-char code if pending approval
 	pairingPending  bool   // true while waiting for admin approval
diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go
index cb77d397..d3ab6b15 100644
--- a/internal/gateway/methods/agent_links.go
+++ b/internal/gateway/methods/agent_links.go
@@ -9,6 +9,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/agent"
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -39,20 +40,21 @@ type linksListParams struct {
 	Direction string `json:"direction"` // "from" (default), "to", "all"
 }
 
-func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.linkStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
 		return
 	}
 
 	var params linksListParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.AgentID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
 		return
 	}
 
@@ -62,7 +64,6 @@ func (m *AgentLinksMethods) handleList(_ context.Context, client *gateway.Client
 		return
 	}
 
-	ctx := context.Background()
 	var links []store.AgentLinkData
 
 	switch params.Direction {
@@ -104,20 +105,21 @@ type linksCreateParams struct {
 	Settings      json.RawMessage `json:"settings"`
 }
 
-func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.linkStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
 		return
 	}
 
 	var params linksCreateParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.SourceAgent == "" || params.TargetAgent == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sourceAgent and targetAgent are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sourceAgent and targetAgent")))
 		return
 	}
 
@@ -126,7 +128,7 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
 		direction = store.LinkDirectionOutbound
 	}
 	if direction != store.LinkDirectionOutbound && direction != store.LinkDirectionInbound && direction != store.LinkDirectionBidirectional {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidDirection)))
 		return
 	}
 
@@ -142,13 +144,13 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
 	}
 
 	if sourceAgent.ID == targetAgent.ID {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source and target must be different agents"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSourceTargetSame)))
 		return
 	}
 
 	// Delegation targets must be predefined agents (open agents have no agent-level context files)
 	if targetAgent.AgentType == store.AgentTypeOpen {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delegate to open agents — only predefined agents can be delegation targets"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDelegateOpen)))
 		return
 	}
 
@@ -168,9 +170,8 @@ func (m *AgentLinksMethods) handleCreate(_ context.Context, client *gateway.Clie
 		CreatedBy:     client.UserID(),
 	}
 
-	ctx := context.Background()
 	if err := m.linkStore.CreateLink(ctx, link); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create link: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "link", err.Error())))
 		return
 	}
 
@@ -217,33 +218,34 @@ type linksUpdateParams struct {
 	Status        string          `json:"status"`
 }
 
-func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentLinksMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.linkStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
 		return
 	}
 
 	var params linksUpdateParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.LinkID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "linkId")))
 		return
 	}
 
 	linkID, err := uuid.Parse(params.LinkID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "linkId")))
 		return
 	}
 
 	updates := map[string]any{}
 	if params.Direction != "" {
 		if params.Direction != store.LinkDirectionOutbound && params.Direction != store.LinkDirectionInbound && params.Direction != store.LinkDirectionBidirectional {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "direction must be outbound, inbound, or bidirectional"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidDirection)))
 			return
 		}
 		updates["direction"] = params.Direction
@@ -259,20 +261,19 @@ func (m *AgentLinksMethods) handleUpdate(_ context.Context, client *gateway.Clie
 	}
 	if params.Status != "" {
 		if params.Status != store.LinkStatusActive && params.Status != store.LinkStatusDisabled {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "status must be active or disabled"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidLinkStatus)))
 			return
 		}
 		updates["status"] = params.Status
 	}
 
 	if len(updates) == 0 {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no updates provided"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgNoUpdatesProvided)))
 		return
 	}
 
-	ctx := context.Background()
 	if err := m.linkStore.UpdateLink(ctx, linkID, updates); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update link: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "link", err.Error())))
 		return
 	}
 
@@ -311,36 +312,35 @@ type linksDeleteParams struct {
 	LinkID string `json:"linkId"`
 }
 
-func (m *AgentLinksMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentLinksMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.linkStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "agent links not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgLinksNotConfigured)))
 		return
 	}
 
 	var params linksDeleteParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.LinkID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "linkId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "linkId")))
 		return
 	}
 
 	linkID, err := uuid.Parse(params.LinkID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid linkId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "linkId")))
 		return
 	}
 
-	ctx := context.Background()
-
 	// Fetch link before deleting to get agent IDs for cache invalidation
 	link, _ := m.linkStore.GetLink(ctx, linkID)
 
 	if err := m.linkStore.DeleteLink(ctx, linkID); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete link: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "link", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go
index 5992f053..25cfb8d7 100644
--- a/internal/gateway/methods/agents.go
+++ b/internal/gateway/methods/agents.go
@@ -8,6 +8,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/agent"
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/tools"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -103,9 +104,10 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien
 
 func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
 	if m.agentStore != nil {
+		locale := store.LocaleFromContext(ctx)
 		userID := client.UserID()
 		if userID == "" {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user context required"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserCtxRequired)))
 			return
 		}
 
@@ -118,7 +120,7 @@ func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client,
 		}
 		if err != nil {
 			slog.Warn("agents.list: store query failed", "error", err)
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list agents"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "agents")))
 			return
 		}
 
diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go
index c54cc416..b4049894 100644
--- a/internal/gateway/methods/agents_create.go
+++ b/internal/gateway/methods/agents_create.go
@@ -11,6 +11,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -18,7 +19,8 @@ import (
 // --- agents.create ---
 // Matching TS src/gateway/server-methods/agents.ts:216-287
 
-func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Name      string   `json:"name"`
 		Workspace string   `json:"workspace"`
@@ -40,7 +42,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 	}
 
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 
@@ -51,7 +53,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 
 	agentID := config.NormalizeAgentID(params.Name)
 	if agentID == "default" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot create agent with reserved id 'default'"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "cannot create agent with reserved id 'default'")))
 		return
 	}
 
@@ -69,7 +71,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 
 		// Check if agent already exists in DB
 		if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAlreadyExists, "agent", agentID)))
 			return
 		}
 
@@ -98,7 +100,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 			OtherConfig:      params.OtherConfig,
 		}
 		if err := m.agentStore.Create(ctx, agentData); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to create agent: %v", err)))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "agent", fmt.Sprintf("%v", err))))
 			return
 		}
 
@@ -120,7 +122,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 	} else {
 		// --- Fallback: config.json + filesystem ---
 		if _, ok := m.cfg.Agents.List[agentID]; ok {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAlreadyExists, "agent", agentID)))
 			return
 		}
 
@@ -140,7 +142,7 @@ func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client,
 		m.cfg.Agents.List[agentID] = spec
 
 		if err := config.Save(m.cfgPath, m.cfg); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
 			return
 		}
 
diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go
index 6c63444c..98459ab8 100644
--- a/internal/gateway/methods/agents_delete.go
+++ b/internal/gateway/methods/agents_delete.go
@@ -8,13 +8,16 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
 // --- agents.delete ---
 // Matching TS src/gateway/server-methods/agents.ts:347-398
 
-func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		AgentID     string `json:"agentId"`
 		DeleteFiles bool   `json:"deleteFiles"`
@@ -25,11 +28,11 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
 	}
 
 	if params.AgentID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
 		return
 	}
 	if params.AgentID == "default" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete the default agent"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDeleteDefault)))
 		return
 	}
 
@@ -40,12 +43,12 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
 		ctx := context.Background()
 		ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
 		if err := m.agentStore.Delete(ctx, ag.ID); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to delete agent: %v", err)))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "agent", fmt.Sprintf("%v", err))))
 			return
 		}
 
@@ -60,7 +63,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
 		// --- Fallback: config.json ---
 		spec, ok := m.cfg.Agents.List[params.AgentID]
 		if !ok {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
@@ -79,7 +82,7 @@ func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client,
 		m.agents.Remove(params.AgentID)
 
 		if err := config.Save(m.cfgPath, m.cfg); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
 			return
 		}
 
diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go
index b11c2bb0..f723ddb8 100644
--- a/internal/gateway/methods/agents_files.go
+++ b/internal/gateway/methods/agents_files.go
@@ -9,6 +9,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -23,7 +24,8 @@ var allowedAgentFiles = []string{
 // --- agents.files.list ---
 // Matching TS src/gateway/server-methods/agents.ts:399-422
 
-func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params agentParams
 	if req.Params != nil {
 		json.Unmarshal(req.Params, ¶ms)
@@ -37,13 +39,13 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien
 		ctx := context.Background()
 		ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
 		dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list files: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
 			return
 		}
 
@@ -110,7 +112,8 @@ func (m *AgentsMethods) handleFilesList(_ context.Context, client *gateway.Clien
 // --- agents.files.get ---
 // Matching TS src/gateway/server-methods/agents.ts:423-473
 
-func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		AgentID string `json:"agentId"`
 		Name    string `json:"name"`
@@ -122,11 +125,11 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
 		params.AgentID = "default"
 	}
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 	if !isAllowedFile(params.Name) {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "file not allowed: "+params.Name))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
 		return
 	}
 
@@ -135,13 +138,13 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
 		ctx := context.Background()
 		ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
 		dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to get files: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
 			return
 		}
 
@@ -207,7 +210,8 @@ func (m *AgentsMethods) handleFilesGet(_ context.Context, client *gateway.Client
 // --- agents.files.set ---
 // Matching TS src/gateway/server-methods/agents.ts:474-515
 
-func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		AgentID string `json:"agentId"`
 		Name    string `json:"name"`
@@ -220,11 +224,11 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
 		params.AgentID = "default"
 	}
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 	if !isAllowedFile(params.Name) {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "file not allowed: "+params.Name))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
 		return
 	}
 
@@ -233,12 +237,12 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
 		ctx := context.Background()
 		ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
 		if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, params.Name, params.Content); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to write file: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
 			return
 		}
 
@@ -267,7 +271,7 @@ func (m *AgentsMethods) handleFilesSet(_ context.Context, client *gateway.Client
 	p := filepath.Join(ws, params.Name)
 
 	if err := os.WriteFile(p, []byte(params.Content), 0644); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to write file: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go
index 4fd59743..1416ce6f 100644
--- a/internal/gateway/methods/agents_update.go
+++ b/internal/gateway/methods/agents_update.go
@@ -10,13 +10,16 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
 // --- agents.update ---
 // Matching TS src/gateway/server-methods/agents.ts:288-346
 
-func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		AgentID   string `json:"agentId"`
 		Name      string `json:"name"`
@@ -37,7 +40,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 	}
 
 	if params.AgentID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "agentId")))
 		return
 	}
 
@@ -46,7 +49,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 		ctx := context.Background()
 		ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 			return
 		}
 
@@ -87,7 +90,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 
 		if len(updates) > 0 {
 			if err := m.agentStore.Update(ctx, ag.ID, updates); err != nil {
-				client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to update agent: %v", err)))
+				client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "agent", fmt.Sprintf("%v", err))))
 				return
 			}
 		}
@@ -110,7 +113,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 		spec, ok := m.cfg.Agents.List[params.AgentID]
 		if !ok {
 			if params.AgentID != "default" {
-				client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID))
+				client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
 				return
 			}
 		}
@@ -147,7 +150,7 @@ func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 		}
 
 		if err := config.Save(m.cfgPath, m.cfg); err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
 			return
 		}
 	}
diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go
index 6b64d2aa..7cc7799b 100644
--- a/internal/gateway/methods/channel_instances.go
+++ b/internal/gateway/methods/channel_instances.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -44,10 +45,11 @@ func (m *ChannelInstancesMethods) emitCacheInvalidate() {
 }
 
 func (m *ChannelInstancesMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	instances, err := m.store.ListAll(ctx)
 	if err != nil {
 		slog.Error("channels.instances.list", "error", err)
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to list channel instances"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "channel instances")))
 		return
 	}
 
@@ -63,6 +65,7 @@ func (m *ChannelInstancesMethods) handleList(ctx context.Context, client *gatewa
 }
 
 func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		ID string `json:"id"`
 	}
@@ -72,13 +75,13 @@ func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway
 
 	id, err := uuid.Parse(params.ID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
 		return
 	}
 
 	inst, err := m.store.Get(ctx, id)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "instance not found"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgInstanceNotFound)))
 		return
 	}
 
@@ -86,6 +89,7 @@ func (m *ChannelInstancesMethods) handleGet(ctx context.Context, client *gateway
 }
 
 func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Name        string          `json:"name"`
 		DisplayName string          `json:"display_name"`
@@ -100,18 +104,18 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
 	}
 
 	if params.Name == "" || params.ChannelType == "" || params.AgentID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name, channel_type, and agent_id are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")))
 		return
 	}
 
 	if !isValidChannelType(params.ChannelType) {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid channel_type"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidChannelType)))
 		return
 	}
 
 	agentID, err := uuid.Parse(params.AgentID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agent_id"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id")))
 		return
 	}
 
@@ -132,7 +136,7 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
 
 	if err := m.store.Create(ctx, inst); err != nil {
 		slog.Error("channels.instances.create", "error", err)
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create instance: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "instance", err.Error())))
 		return
 	}
 
@@ -141,6 +145,7 @@ func (m *ChannelInstancesMethods) handleCreate(ctx context.Context, client *gate
 }
 
 func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		ID      string          `json:"id"`
 		Updates json.RawMessage `json:"updates"`
@@ -151,19 +156,19 @@ func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gate
 
 	id, err := uuid.Parse(params.ID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.Unmarshal(params.Updates, &updates); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid updates"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
 		return
 	}
 
 	if err := m.store.Update(ctx, id, updates); err != nil {
 		slog.Error("channels.instances.update", "error", err)
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update instance: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "instance", err.Error())))
 		return
 	}
 
@@ -172,6 +177,7 @@ func (m *ChannelInstancesMethods) handleUpdate(ctx context.Context, client *gate
 }
 
 func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		ID string `json:"id"`
 	}
@@ -181,24 +187,24 @@ func (m *ChannelInstancesMethods) handleDelete(ctx context.Context, client *gate
 
 	id, err := uuid.Parse(params.ID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid instance ID"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "instance")))
 		return
 	}
 
 	// Look up instance to check if it's a default (seeded) instance.
 	inst, err := m.store.Get(ctx, id)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "instance not found"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInstanceNotFound)))
 		return
 	}
 	if store.IsDefaultChannelInstance(inst.Name) {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete default channel instance"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)))
 		return
 	}
 
 	if err := m.store.Delete(ctx, id); err != nil {
 		slog.Error("channels.instances.delete", "error", err)
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete instance: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "instance", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/methods/channels.go b/internal/gateway/methods/channels.go
index a0a2d053..f40c7d8a 100644
--- a/internal/gateway/methods/channels.go
+++ b/internal/gateway/methods/channels.go
@@ -5,6 +5,8 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/channels"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
@@ -39,7 +41,8 @@ func (m *ChannelsMethods) handleStatus(_ context.Context, client *gateway.Client
 	}))
 }
 
-func (m *ChannelsMethods) handleToggle(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *ChannelsMethods) handleToggle(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	// Channel toggling requires restarting the channel, which is a Phase 3 feature.
-	client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "channels.toggle not yet implemented"))
+	client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotImplemented, "channels.toggle")))
 }
diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go
index d30965f8..162ad1e9 100644
--- a/internal/gateway/methods/chat.go
+++ b/internal/gateway/methods/chat.go
@@ -10,6 +10,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/channels/media"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/providers"
 	"github.com/nextlevelbuilder/goclaw/internal/sessions"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -44,6 +45,7 @@ type chatSendParams struct {
 }
 
 func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	// Rate limit check per user/client
 	if m.rateLimiter != nil && m.rateLimiter.Enabled() {
 		key := client.UserID()
@@ -51,14 +53,14 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
 			key = client.ID()
 		}
 		if !m.rateLimiter.Allow(key) {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "rate limit exceeded — please wait before sending more messages"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRateLimitExceeded)))
 			return
 		}
 	}
 
 	var params chatSendParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
@@ -83,7 +85,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
 
 	userID := client.UserID()
 	if userID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "user_id is required in managed mode — provide it in the connect handshake"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDRequired)))
 		return
 	}
 
@@ -171,9 +173,10 @@ type chatHistoryParams struct {
 }
 
 func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params chatHistoryParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
@@ -195,23 +198,24 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client,
 
 // handleInject injects a message into a session transcript without running the agent.
 // Matching TS chat.inject (src/gateway/server-methods/chat.ts:686-746).
-func (m *ChatMethods) handleInject(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		SessionKey string `json:"sessionKey"`
 		Message    string `json:"message"`
 		Label      string `json:"label"`
 	}
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.SessionKey == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey")))
 		return
 	}
 	if params.Message == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
 		return
 	}
 
@@ -249,18 +253,19 @@ func (m *ChatMethods) handleInject(_ context.Context, client *gateway.Client, re
 // Response:
 //
 //	{ ok: true, aborted: bool, runIds: []string }
-func (m *ChatMethods) handleAbort(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *ChatMethods) handleAbort(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		RunID      string `json:"runId"`
 		SessionKey string `json:"sessionKey"`
 	}
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.SessionKey == "" && params.RunID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sessionKey or runId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "sessionKey or runId")))
 		return
 	}
 
diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go
index e84240ec..0543b803 100644
--- a/internal/gateway/methods/config.go
+++ b/internal/gateway/methods/config.go
@@ -10,6 +10,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/config"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -45,6 +46,7 @@ func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req
 // handleApply replaces the entire config with the provided JSON5 raw content.
 // Matching TS config.apply (src/gateway/server-methods/config.ts:435-486).
 func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Raw      string `json:"raw"`
 		BaseHash string `json:"baseHash"`
@@ -54,20 +56,20 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
 	}
 
 	if params.Raw == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "raw config is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawConfigRequired)))
 		return
 	}
 
 	// Optimistic concurrency: validate hash if provided
 	if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "config has changed (hash mismatch)"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
 		return
 	}
 
 	// Parse the new config
 	newCfg := config.Default()
 	if err := json5.Unmarshal([]byte(params.Raw), newCfg); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid config: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
 		return
 	}
 
@@ -77,7 +79,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
 
 	// Save to disk
 	if err := config.Save(m.cfgPath, newCfg); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
 		return
 	}
 
@@ -103,6 +105,7 @@ func (m *ConfigMethods) handleApply(ctx context.Context, client *gateway.Client,
 // handlePatch merges a partial config update into the current config.
 // Matching TS config.patch (src/gateway/server-methods/config.ts:321-434).
 func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Raw      string `json:"raw"`
 		BaseHash string `json:"baseHash"`
@@ -112,33 +115,33 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
 	}
 
 	if params.Raw == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "raw patch is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRawPatchRequired)))
 		return
 	}
 
 	// Optimistic concurrency
 	if params.BaseHash != "" && params.BaseHash != m.cfg.Hash() {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "config has changed (hash mismatch)"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgConfigHashMismatch)))
 		return
 	}
 
 	// Merge strategy: serialize current -> deserialize patch on top -> save
 	currentJSON, err := json.Marshal(m.cfg)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to serialize current config"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to serialize current config")))
 		return
 	}
 
 	// Start from current config as base
 	merged := config.Default()
 	if err := json.Unmarshal(currentJSON, merged); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to clone config"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to clone config")))
 		return
 	}
 
 	// Apply patch on top
 	if err := json5.Unmarshal([]byte(params.Raw), merged); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid patch: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
 		return
 	}
 
@@ -148,7 +151,7 @@ func (m *ConfigMethods) handlePatch(ctx context.Context, client *gateway.Client,
 
 	// Save to disk
 	if err := config.Save(m.cfgPath, merged); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "config", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go
index 5506a5aa..1968f318 100644
--- a/internal/gateway/methods/cron.go
+++ b/internal/gateway/methods/cron.go
@@ -7,6 +7,7 @@ import (
 	"regexp"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -49,30 +50,31 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req
 	}))
 }
 
-func (m *CronMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
-		Name     string        `json:"name"`
+		Name     string             `json:"name"`
 		Schedule store.CronSchedule `json:"schedule"`
-		Message  string        `json:"message"`
-		Deliver  bool          `json:"deliver"`
-		Channel  string        `json:"channel"`
-		To       string        `json:"to"`
-		AgentID  string        `json:"agentId"`
+		Message  string             `json:"message"`
+		Deliver  bool               `json:"deliver"`
+		Channel  string             `json:"channel"`
+		To       string             `json:"to"`
+		AgentID  string             `json:"agentId"`
 	}
 	if req.Params != nil {
 		json.Unmarshal(req.Params, ¶ms)
 	}
 
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 	if !cronSlugRe.MatchString(params.Name) {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name must be a valid slug (lowercase letters, numbers, hyphens only)"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "name")))
 		return
 	}
 	if params.Message == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
 		return
 	}
 
@@ -87,7 +89,8 @@ func (m *CronMethods) handleCreate(_ context.Context, client *gateway.Client, re
 	}))
 }
 
-func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		JobID string `json:"jobId"`
 	}
@@ -96,7 +99,7 @@ func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, re
 	}
 
 	if params.JobID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
 		return
 	}
 
@@ -110,7 +113,8 @@ func (m *CronMethods) handleDelete(_ context.Context, client *gateway.Client, re
 	}))
 }
 
-func (m *CronMethods) handleToggle(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		JobID   string `json:"jobId"`
 		Enabled bool   `json:"enabled"`
@@ -120,7 +124,7 @@ func (m *CronMethods) handleToggle(_ context.Context, client *gateway.Client, re
 	}
 
 	if params.JobID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
 		return
 	}
 
@@ -139,10 +143,11 @@ func (m *CronMethods) handleStatus(_ context.Context, client *gateway.Client, re
 	client.SendResponse(protocol.NewOKResponse(req.ID, m.service.Status()))
 }
 
-func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
-		JobID string        `json:"jobId"`
-		ID    string        `json:"id"` // alias (matching TS)
+		JobID string            `json:"jobId"`
+		ID    string            `json:"id"` // alias (matching TS)
 		Patch store.CronJobPatch `json:"patch"`
 	}
 	if req.Params != nil {
@@ -154,7 +159,7 @@ func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, re
 		jobID = params.ID
 	}
 	if jobID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
 		return
 	}
 
@@ -169,7 +174,8 @@ func (m *CronMethods) handleUpdate(_ context.Context, client *gateway.Client, re
 	}))
 }
 
-func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		JobID string `json:"jobId"`
 		ID    string `json:"id"`
@@ -184,7 +190,7 @@ func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *
 		jobID = params.ID
 	}
 	if jobID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "jobId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "jobId")))
 		return
 	}
 
@@ -193,7 +199,7 @@ func (m *CronMethods) handleRun(_ context.Context, client *gateway.Client, req *
 	// Validate job exists before responding
 	_, ok := m.service.GetJob(jobID)
 	if !ok {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "job not found"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound)))
 		return
 	}
 
diff --git a/internal/gateway/methods/delegations.go b/internal/gateway/methods/delegations.go
index dd2aea79..14b1811f 100644
--- a/internal/gateway/methods/delegations.go
+++ b/internal/gateway/methods/delegations.go
@@ -7,6 +7,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -25,9 +26,10 @@ func (m *DelegationsMethods) Register(router *gateway.MethodRouter) {
 	router.Register(protocol.MethodDelegationsGet, m.handleGet)
 }
 
-func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "delegations not available"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
 		return
 	}
 
@@ -67,7 +69,6 @@ func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Clien
 		}
 	}
 
-	ctx := context.Background()
 	records, total, err := m.teamStore.ListDelegationHistory(ctx, opts)
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -92,9 +93,10 @@ func (m *DelegationsMethods) handleList(_ context.Context, client *gateway.Clien
 	}))
 }
 
-func (m *DelegationsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "delegations not available"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
 		return
 	}
 
@@ -105,17 +107,16 @@ func (m *DelegationsMethods) handleGet(_ context.Context, client *gateway.Client
 		_ = json.Unmarshal(req.Params, ¶ms)
 	}
 	if params.ID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
 		return
 	}
 
 	id, err := uuid.Parse(params.ID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid id"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id")))
 		return
 	}
 
-	ctx := context.Background()
 	record, err := m.teamStore.GetDelegationHistory(ctx, id)
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
diff --git a/internal/gateway/methods/exec_approval.go b/internal/gateway/methods/exec_approval.go
index c89971b8..76ffd644 100644
--- a/internal/gateway/methods/exec_approval.go
+++ b/internal/gateway/methods/exec_approval.go
@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/tools"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -55,22 +57,23 @@ func (m *ExecApprovalMethods) handleList(_ context.Context, client *gateway.Clie
 	}))
 }
 
-func (m *ExecApprovalMethods) handleApprove(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *ExecApprovalMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.manager == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "exec approval is not enabled"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgExecApprovalDisabled)))
 		return
 	}
 
 	var params struct {
-		ID    string `json:"id"`
-		Always bool  `json:"always"` // true = allow-always, false = allow-once
+		ID     string `json:"id"`
+		Always bool   `json:"always"` // true = allow-always, false = allow-once
 	}
 	if req.Params != nil {
 		json.Unmarshal(req.Params, ¶ms)
 	}
 
 	if params.ID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
 		return
 	}
 
@@ -90,9 +93,10 @@ func (m *ExecApprovalMethods) handleApprove(_ context.Context, client *gateway.C
 	}))
 }
 
-func (m *ExecApprovalMethods) handleDeny(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *ExecApprovalMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.manager == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "exec approval is not enabled"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgExecApprovalDisabled)))
 		return
 	}
 
@@ -104,7 +108,7 @@ func (m *ExecApprovalMethods) handleDeny(_ context.Context, client *gateway.Clie
 	}
 
 	if params.ID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "id is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
 		return
 	}
 
diff --git a/internal/gateway/methods/logs.go b/internal/gateway/methods/logs.go
index 18d3795b..21675a35 100644
--- a/internal/gateway/methods/logs.go
+++ b/internal/gateway/methods/logs.go
@@ -6,6 +6,8 @@ import (
 	"log/slog"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
@@ -22,7 +24,8 @@ func (m *LogsMethods) Register(router *gateway.MethodRouter) {
 	router.Register(protocol.MethodLogsTail, m.handleTail)
 }
 
-func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *LogsMethods) handleTail(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Action string `json:"action"`
 		Level  string `json:"level"` // "debug", "info", "warn", "error" (default: "info")
@@ -48,7 +51,7 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
 		client.SendResponse(protocol.NewErrorResponse(
 			req.ID,
 			protocol.ErrInvalidRequest,
-			"action must be 'start' or 'stop'",
+			i18n.T(locale, i18n.MsgInvalidLogAction),
 		))
 	}
 }
diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go
index 9b1b3e44..f519832c 100644
--- a/internal/gateway/methods/pairing.go
+++ b/internal/gateway/methods/pairing.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -46,7 +47,8 @@ func (m *PairingMethods) Register(router *gateway.MethodRouter) {
 	router.Register(protocol.MethodBrowserPairingStatus, m.handleBrowserPairingStatus)
 }
 
-func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		SenderID  string `json:"senderId"`
 		Channel   string `json:"channel"`
@@ -58,7 +60,7 @@ func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client
 	}
 
 	if params.SenderID == "" || params.Channel == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "senderId and channel are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
 		return
 	}
 
@@ -78,6 +80,7 @@ func (m *PairingMethods) handleRequest(_ context.Context, client *gateway.Client
 }
 
 func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Code       string `json:"code"`
 		ApprovedBy string `json:"approvedBy"`
@@ -87,7 +90,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
 	}
 
 	if params.Code == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "code is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
 		return
 	}
 	if params.ApprovedBy == "" {
@@ -115,7 +118,8 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
 	}))
 }
 
-func (m *PairingMethods) handleDeny(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Code string `json:"code"`
 	}
@@ -124,7 +128,7 @@ func (m *PairingMethods) handleDeny(_ context.Context, client *gateway.Client, r
 	}
 
 	if params.Code == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "code is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCodeRequired)))
 		return
 	}
 
@@ -152,7 +156,8 @@ func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, r
 	}))
 }
 
-func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		SenderID string `json:"senderId"`
 		Channel  string `json:"channel"`
@@ -162,7 +167,7 @@ func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client,
 	}
 
 	if params.SenderID == "" || params.Channel == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "senderId and channel are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderChannelRequired)))
 		return
 	}
 
@@ -193,7 +198,8 @@ func (m *PairingMethods) handleRevoke(_ context.Context, client *gateway.Client,
 
 // handleBrowserPairingStatus lets a pending browser client check if its pairing code has been approved.
 // Called by unauthenticated clients during the browser pairing flow.
-func (m *PairingMethods) handleBrowserPairingStatus(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		SenderID string `json:"sender_id"`
 	}
@@ -202,7 +208,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(_ context.Context, client *g
 	}
 
 	if params.SenderID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "sender_id is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgSenderIDRequired)))
 		return
 	}
 
diff --git a/internal/gateway/methods/send.go b/internal/gateway/methods/send.go
index 0d0c190a..589cd25d 100644
--- a/internal/gateway/methods/send.go
+++ b/internal/gateway/methods/send.go
@@ -6,6 +6,8 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
@@ -23,7 +25,8 @@ func (m *SendMethods) Register(router *gateway.MethodRouter) {
 	router.Register(protocol.MethodSend, m.handleSend)
 }
 
-func (m *SendMethods) handleSend(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SendMethods) handleSend(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Channel string `json:"channel"`
 		To      string `json:"to"`
@@ -34,15 +37,15 @@ func (m *SendMethods) handleSend(_ context.Context, client *gateway.Client, req
 	}
 
 	if params.Channel == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "channel is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "channel")))
 		return
 	}
 	if params.To == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "to is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "to")))
 		return
 	}
 	if params.Message == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "message is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgMsgRequired)))
 		return
 	}
 
diff --git a/internal/gateway/methods/sessions.go b/internal/gateway/methods/sessions.go
index ec929841..965d72d0 100644
--- a/internal/gateway/methods/sessions.go
+++ b/internal/gateway/methods/sessions.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -59,10 +60,11 @@ type sessionKeyParams struct {
 	Key string `json:"key"`
 }
 
-func (m *SessionsMethods) handlePreview(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params sessionKeyParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
@@ -78,7 +80,8 @@ func (m *SessionsMethods) handlePreview(_ context.Context, client *gateway.Clien
 
 // handlePatch updates session metadata fields.
 // Matching TS sessions.patch (src/gateway/server-methods/sessions.ts:237-287).
-func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Key      string            `json:"key"`
 		Label    *string           `json:"label,omitempty"`
@@ -86,12 +89,12 @@ func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client,
 		Metadata map[string]string `json:"metadata,omitempty"`
 	}
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.Key == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "key is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "key")))
 		return
 	}
 
@@ -119,10 +122,11 @@ func (m *SessionsMethods) handlePatch(_ context.Context, client *gateway.Client,
 	}))
 }
 
-func (m *SessionsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params sessionKeyParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
@@ -136,10 +140,11 @@ func (m *SessionsMethods) handleDelete(_ context.Context, client *gateway.Client
 	}))
 }
 
-func (m *SessionsMethods) handleReset(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params sessionKeyParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go
index 313fe816..3332b003 100644
--- a/internal/gateway/methods/skills.go
+++ b/internal/gateway/methods/skills.go
@@ -7,6 +7,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -55,7 +56,8 @@ func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, re
 	}))
 }
 
-func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Name string `json:"name"`
 	}
@@ -63,13 +65,13 @@ func (m *SkillsMethods) handleGet(_ context.Context, client *gateway.Client, req
 		json.Unmarshal(req.Params, ¶ms)
 	}
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 
 	info, ok := m.store.GetSkill(params.Name)
 	if !ok {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skill not found: "+params.Name))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
 		return
 	}
 
@@ -100,7 +102,8 @@ type skillUpdater interface {
 	UpdateSkill(id uuid.UUID, updates map[string]interface{}) error
 }
 
-func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	var params struct {
 		Name    string                 `json:"name"`
 		ID      string                 `json:"id"`
@@ -110,14 +113,14 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 		json.Unmarshal(req.Params, ¶ms)
 	}
 	if params.Name == "" && params.ID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name or id is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name or id")))
 		return
 	}
 
 	// Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't)
 	updater, ok := m.store.(skillUpdater)
 	if !ok {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skills.update not supported for file-based skills"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgSkillsUpdateNotSupported)))
 		return
 	}
 
@@ -126,7 +129,7 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 	if params.ID != "" {
 		parsed, err := uuid.Parse(params.ID)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid skill ID"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "skill")))
 			return
 		}
 		skillID = parsed
@@ -135,20 +138,20 @@ func (m *SkillsMethods) handleUpdate(_ context.Context, client *gateway.Client,
 		// For PGSkillStore, the name is the slug
 		info, exists := m.store.GetSkill(params.Name)
 		if !exists {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "skill not found: "+params.Name))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
 			return
 		}
 		// Try to parse Path as UUID (PGSkillStore stores DB ID in Path field for managed skills)
 		parsed, err := uuid.Parse(info.Path)
 		if err != nil {
-			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot resolve skill ID for file-based skill"))
+			client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotResolveSkillID)))
 			return
 		}
 		skillID = parsed
 	}
 
 	if params.Updates == nil || len(params.Updates) == 0 {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "updates is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "updates")))
 		return
 	}
 
diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go
index f6c04450..cc3af568 100644
--- a/internal/gateway/methods/teams.go
+++ b/internal/gateway/methods/teams.go
@@ -8,6 +8,7 @@ import (
 	"github.com/nextlevelbuilder/goclaw/internal/agent"
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -50,13 +51,13 @@ func (m *TeamsMethods) Register(router *gateway.MethodRouter) {
 
 // --- List ---
 
-func (m *TeamsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
-	ctx := context.Background()
 	teams, err := m.teamStore.ListTeams(ctx)
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -79,24 +80,25 @@ type teamsCreateParams struct {
 	Settings    json.RawMessage `json:"settings"`
 }
 
-func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsCreateParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.Name == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
 		return
 	}
 	if params.Lead == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "lead")))
 		return
 	}
 
@@ -118,8 +120,6 @@ func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, r
 		memberAgents = append(memberAgents, ag)
 	}
 
-	ctx := context.Background()
-
 	// Create team
 	team := &store.TeamData{
 		Name:        params.Name,
@@ -130,13 +130,13 @@ func (m *TeamsMethods) handleCreate(_ context.Context, client *gateway.Client, r
 		CreatedBy:   client.UserID(),
 	}
 	if err := m.teamStore.CreateTeam(ctx, team); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to create team: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team", err.Error())))
 		return
 	}
 
 	// Add lead as member with lead role
 	if err := m.teamStore.AddMember(ctx, team.ID, leadAgent.ID, store.TeamRoleLead); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add lead as member: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "team lead membership", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go
index 9ff7a09c..5b572641 100644
--- a/internal/gateway/methods/teams_crud.go
+++ b/internal/gateway/methods/teams_crud.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -18,30 +19,30 @@ type teamsGetParams struct {
 	TeamID string `json:"teamId"`
 }
 
-func (m *TeamsMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsGetParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.TeamID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
 	team, err := m.teamStore.GetTeam(ctx, teamID)
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -66,37 +67,36 @@ type teamsDeleteParams struct {
 	TeamID string `json:"teamId"`
 }
 
-func (m *TeamsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsDeleteParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.TeamID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
-
 	// Fetch team and members before deleting for event + cache invalidation
 	team, _ := m.teamStore.GetTeam(ctx, teamID)
 	members, _ := m.teamStore.ListMembers(ctx, teamID)
 
 	if err := m.teamStore.DeleteTeam(ctx, teamID); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to delete team: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "team", err.Error())))
 		return
 	}
 
@@ -127,30 +127,30 @@ type teamsTaskListParams struct {
 	TeamID string `json:"teamId"`
 }
 
-func (m *TeamsMethods) handleTaskList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleTaskList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsTaskListParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.TeamID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
 	tasks, err := m.teamStore.ListTasks(ctx, teamID, "newest", store.TeamTaskFilterAll, "")
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -170,35 +170,34 @@ type teamsUpdateParams struct {
 	Settings map[string]interface{} `json:"settings"`
 }
 
-func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsUpdateParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.TeamID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
-
 	// Validate team exists
 	team, err := m.teamStore.GetTeam(ctx, teamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
 		return
 	}
 
@@ -213,14 +212,14 @@ func (m *TeamsMethods) handleUpdate(_ context.Context, client *gateway.Client, r
 	raw, _ := json.Marshal(params.Settings)
 	var access teamAccessSettings
 	if err := json.Unmarshal(raw, &access); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid settings: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())))
 		return
 	}
 	cleaned, _ := json.Marshal(access)
 
 	updates := map[string]any{"settings": json.RawMessage(cleaned)}
 	if err := m.teamStore.UpdateTeam(ctx, teamID, updates); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to update team: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "team", err.Error())))
 		return
 	}
 
@@ -251,30 +250,30 @@ type teamsKnownUsersParams struct {
 	TeamID string `json:"teamId"`
 }
 
-func (m *TeamsMethods) handleKnownUsers(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleKnownUsers(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsKnownUsersParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 
 	if params.TeamID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId is required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
 	users, err := m.teamStore.KnownUserIDs(ctx, teamID, 100)
 	if err != nil {
 		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go
index 6d153c45..179aeb78 100644
--- a/internal/gateway/methods/teams_members.go
+++ b/internal/gateway/methods/teams_members.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
 	"github.com/nextlevelbuilder/goclaw/internal/gateway"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -20,34 +21,33 @@ type teamsAddMemberParams struct {
 	Agent  string `json:"agent"` // agent key or UUID
 }
 
-func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsAddMemberParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 	if params.TeamID == "" || params.Agent == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agent are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId and agent")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 
-	ctx := context.Background()
-
 	// Validate team exists
 	team, err := m.teamStore.GetTeam(ctx, teamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
 		return
 	}
 
@@ -60,13 +60,13 @@ func (m *TeamsMethods) handleAddMember(_ context.Context, client *gateway.Client
 
 	// Prevent adding lead again
 	if ag.ID == team.LeadAgentID {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent is already the team lead"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgAgentIsTeamLead)))
 		return
 	}
 
 	// Add member
 	if err := m.teamStore.AddMember(ctx, teamID, ag.ID, store.TeamRoleMember); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to add member: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "member", err.Error())))
 		return
 	}
 
@@ -106,43 +106,42 @@ type teamsRemoveMemberParams struct {
 	AgentID string `json:"agentId"` // agent UUID
 }
 
-func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+func (m *TeamsMethods) handleRemoveMember(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+	locale := store.LocaleFromContext(ctx)
 	if m.teamStore == nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "teams not configured"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgTeamsNotConfigured)))
 		return
 	}
 
 	var params teamsRemoveMemberParams
 	if err := json.Unmarshal(req.Params, ¶ms); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
 		return
 	}
 	if params.TeamID == "" || params.AgentID == "" {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "teamId and agentId are required"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "teamId and agentId")))
 		return
 	}
 
 	teamID, err := uuid.Parse(params.TeamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid teamId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "teamId")))
 		return
 	}
 	agentID, err := uuid.Parse(params.AgentID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid agentId"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agentId")))
 		return
 	}
 
-	ctx := context.Background()
-
 	// Validate team exists and prevent removing the lead
 	team, err := m.teamStore.GetTeam(ctx, teamID)
 	if err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "team not found: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgNotFound, "team", err.Error())))
 		return
 	}
 	if agentID == team.LeadAgentID {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot remove the team lead"))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotRemoveTeamLead)))
 		return
 	}
 
@@ -151,7 +150,7 @@ func (m *TeamsMethods) handleRemoveMember(_ context.Context, client *gateway.Cli
 
 	// Remove member
 	if err := m.teamStore.RemoveMember(ctx, teamID, agentID); err != nil {
-		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to remove member: "+err.Error()))
+		client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "member", err.Error())))
 		return
 	}
 
diff --git a/internal/gateway/router.go b/internal/gateway/router.go
index dfee89a6..3a5b8d3a 100644
--- a/internal/gateway/router.go
+++ b/internal/gateway/router.go
@@ -6,7 +6,9 @@ import (
 	"log/slog"
 	"time"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/permissions"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
 
@@ -38,10 +40,11 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
 	handler, ok := r.handlers[req.Method]
 	if !ok {
 		slog.Warn("unknown method", "method", req.Method, "client", client.id)
+		locale := i18n.Normalize(client.locale)
 		client.SendResponse(protocol.NewErrorResponse(
 			req.ID,
 			protocol.ErrInvalidRequest,
-			"unknown method: "+req.Method,
+			i18n.T(locale, i18n.MsgUnknownMethod, req.Method),
 		))
 		return
 	}
@@ -51,16 +54,20 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
 		if pe := r.server.policyEngine; pe != nil {
 			if !pe.CanAccess(client.role, req.Method) {
 				slog.Warn("permission denied", "method", req.Method, "role", client.role, "client", client.id)
+				locale := i18n.Normalize(client.locale)
 				client.SendResponse(protocol.NewErrorResponse(
 					req.ID,
 					protocol.ErrUnauthorized,
-					"permission denied: insufficient role for "+req.Method,
+					i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
 				))
 				return
 			}
 		}
 	}
 
+	// Inject locale into context for i18n support
+	ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
+
 	slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
 	handler(ctx, client, req)
 }
@@ -81,11 +88,15 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
 		Token    string `json:"token"`
 		UserID   string `json:"user_id"`
 		SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
+		Locale   string `json:"locale"`    // user's preferred locale (en, vi, zh)
 	}
 	if req.Params != nil {
 		json.Unmarshal(req.Params, ¶ms)
 	}
 
+	// Set locale on client (persists across all requests for this connection)
+	client.locale = i18n.Normalize(params.Locale)
+
 	configToken := r.server.cfg.Gateway.Token
 
 	// Path 1: Valid token → admin
diff --git a/internal/http/agents.go b/internal/http/agents.go
index d2b24bf6..cda4991d 100644
--- a/internal/http/agents.go
+++ b/internal/http/agents.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -68,16 +69,18 @@ func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
-		// Inject user_id into context
+		// Inject user_id and locale into context
 		userID := extractUserID(r)
+		ctx := store.WithLocale(r.Context(), extractLocale(r))
 		if userID != "" {
-			ctx := store.WithUserID(r.Context(), userID)
-			r = r.WithContext(ctx)
+			ctx = store.WithUserID(ctx, userID)
 		}
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -85,7 +88,8 @@ func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
 func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
 	if userID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
+		locale := store.LocaleFromContext(r.Context())
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
 		return
 	}
 
@@ -106,25 +110,26 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
 
 func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	if userID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
 		return
 	}
 
 	var req store.AgentData
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 
 	if !isValidSlug(req.AgentKey) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent_key must be a valid slug (lowercase letters, numbers, hyphens only)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")})
 		return
 	}
 
 	// Check for duplicate agent_key before creating
 	if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil {
-		writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with key \"" + req.AgentKey + "\" already exists"})
+		writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
 		return
 	}
 
@@ -161,7 +166,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
 
 	if err := h.agents.Create(r.Context(), &req); err != nil {
 		if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
-			writeJSON(w, http.StatusConflict, map[string]string{"error": "An agent with this key already exists"})
+			writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
 		} else {
 			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
 		}
@@ -184,6 +189,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
 
 func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	isOwner := h.isOwnerUser(userID)
 
 	id, err := uuid.Parse(r.PathValue("id"))
@@ -191,12 +197,12 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
 		// Try by agent_key
 		ag, err2 := h.agents.GetByKey(r.Context(), r.PathValue("id"))
 		if err2 != nil {
-			writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+			writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))})
 			return
 		}
 		if userID != "" && !isOwner {
 			if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok {
-				writeJSON(w, http.StatusForbidden, map[string]string{"error": "no access to this agent"})
+				writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
 				return
 			}
 		}
@@ -206,13 +212,13 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 
 	if userID != "" && !isOwner {
 		if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok {
-			writeJSON(w, http.StatusForbidden, map[string]string{"error": "no access to this agent"})
+			writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
 			return
 		}
 	}
@@ -222,26 +228,27 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
 
 func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can update
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can update agent"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "update agent")})
 		return
 	}
 
 	var updates map[string]any
 	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 
@@ -277,20 +284,21 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
 
 func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can delete
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can delete agent"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")})
 		return
 	}
 
diff --git a/internal/http/agents_instances.go b/internal/http/agents_instances.go
index 7ae5848e..77a96899 100644
--- a/internal/http/agents_instances.go
+++ b/internal/http/agents_instances.go
@@ -8,25 +8,27 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 // handleListInstances returns all user instances for a predefined agent.
 func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instances"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view instances")})
 		return
 	}
 
@@ -42,24 +44,25 @@ func (h *AgentsHandler) handleListInstances(w http.ResponseWriter, r *http.Reque
 // handleGetInstanceFiles returns user context files for a specific instance.
 func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Request) {
 	callerID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 	instanceUserID := r.PathValue("userID")
 	if instanceUserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID")})
 		return
 	}
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view instance files"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view instance files")})
 		return
 	}
 
@@ -75,44 +78,45 @@ func (h *AgentsHandler) handleGetInstanceFiles(w http.ResponseWriter, r *http.Re
 // handleSetInstanceFile updates a user context file for a specific instance.
 func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Request) {
 	callerID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 	instanceUserID := r.PathValue("userID")
 	fileName := r.PathValue("fileName")
 	if instanceUserID == "" || fileName == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID or fileName"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID and fileName")})
 		return
 	}
 
 	// Only USER.md can be edited via this endpoint — other files are managed by the agent itself.
 	if fileName != "USER.md" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only USER.md can be edited via this endpoint"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "only USER.md can be edited via this endpoint")})
 		return
 	}
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance files"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "edit instance files")})
 		return
 	}
 
 	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 	var payload struct {
 		Content string `json:"content"`
 	}
 	if err := json.Unmarshal(body, &payload); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
@@ -130,41 +134,42 @@ func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Req
 // handleUpdateInstanceMetadata updates metadata for a user instance.
 func (h *AgentsHandler) handleUpdateInstanceMetadata(w http.ResponseWriter, r *http.Request) {
 	callerID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 	instanceUserID := r.PathValue("userID")
 	if instanceUserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing userID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userID")})
 		return
 	}
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if callerID != "" && ag.OwnerID != callerID && !h.isOwnerUser(callerID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can edit instance metadata"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "edit instance metadata")})
 		return
 	}
 
 	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 	var payload struct {
 		Metadata map[string]string `json:"metadata"`
 	}
 	if err := json.Unmarshal(body, &payload); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if len(payload.Metadata) == 0 {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "metadata is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "metadata")})
 		return
 	}
 
diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go
index 3f6a6aa2..70b513dd 100644
--- a/internal/http/agents_sharing.go
+++ b/internal/http/agents_sharing.go
@@ -6,25 +6,27 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can list shares
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can view shares"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "view shares")})
 		return
 	}
 
@@ -39,20 +41,21 @@ func (h *AgentsHandler) handleListShares(w http.ResponseWriter, r *http.Request)
 
 func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can share
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can share agent"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "share agent")})
 		return
 	}
 
@@ -61,11 +64,11 @@ func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
 		Role   string `json:"role"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 	if req.UserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
 		return
 	}
 	if err := store.ValidateUserID(req.UserID); err != nil {
@@ -86,20 +89,21 @@ func (h *AgentsHandler) handleShare(w http.ResponseWriter, r *http.Request) {
 
 func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can revoke shares
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can revoke shares"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "revoke shares")})
 		return
 	}
 
@@ -118,28 +122,29 @@ func (h *AgentsHandler) handleRevokeShare(w http.ResponseWriter, r *http.Request
 
 func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	// Only owner can regenerate
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can regenerate agent"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "regenerate agent")})
 		return
 	}
 	if ag.Status == store.AgentStatusSummoning {
-		writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
+		writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadySummoning)})
 		return
 	}
 	if h.summoner == nil {
-		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
+		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgSummoningUnavailable)})
 		return
 	}
 
@@ -147,11 +152,11 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
 		Prompt string `json:"prompt"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
 		return
 	}
 	if req.Prompt == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "prompt")})
 		return
 	}
 
@@ -170,33 +175,34 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
 // Used when initial summoning failed (e.g. wrong model) and user wants to retry.
 func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
 	userID := store.UserIDFromContext(r.Context())
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	ag, err := h.agents.GetByID(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "agent not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
 		return
 	}
 	if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "only owner can resummon agent"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "resummon agent")})
 		return
 	}
 	if ag.Status == store.AgentStatusSummoning {
-		writeJSON(w, http.StatusConflict, map[string]string{"error": "agent is already being summoned"})
+		writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadySummoning)})
 		return
 	}
 	if h.summoner == nil {
-		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "summoning not available"})
+		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgSummoningUnavailable)})
 		return
 	}
 
 	description := extractDescription(ag.OtherConfig)
 	if description == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent has no description to resummon from"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoDescription)})
 		return
 	}
 
diff --git a/internal/http/auth.go b/internal/http/auth.go
index 28bee0cd..38df3c5d 100644
--- a/internal/http/auth.go
+++ b/internal/http/auth.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -66,3 +67,21 @@ func extractAgentID(r *http.Request, model string) string {
 
 	return "default"
 }
+
+// extractLocale parses the Accept-Language header and returns a supported locale.
+// Falls back to "en" if no supported language is found.
+func extractLocale(r *http.Request) string {
+	accept := r.Header.Get("Accept-Language")
+	if accept == "" {
+		return i18n.DefaultLocale
+	}
+	// Simple parser: take the first language tag before comma or semicolon
+	for _, part := range strings.Split(accept, ",") {
+		tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
+		locale := i18n.Normalize(tag)
+		if locale != i18n.DefaultLocale || strings.HasPrefix(tag, "en") {
+			return locale
+		}
+	}
+	return i18n.DefaultLocale
+}
diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go
index 59ac5364..0b19fcf1 100644
--- a/internal/http/builtin_tools.go
+++ b/internal/http/builtin_tools.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -34,7 +35,8 @@ func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -56,22 +58,24 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
 	result, err := h.store.List(r.Context())
 	if err != nil {
 		slog.Error("builtin_tools.list", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"})
+		locale := extractLocale(r)
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
 		return
 	}
 	writeJSON(w, http.StatusOK, map[string]interface{}{"tools": result})
 }
 
 func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	name := r.PathValue("name")
 	if name == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
 		return
 	}
 
 	def, err := h.store.Get(r.Context(), name)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "tool not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", name)})
 		return
 	}
 
@@ -79,15 +83,16 @@ func (h *BuiltinToolsHandler) handleGet(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	name := r.PathValue("name")
 	if name == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
@@ -101,7 +106,7 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques
 	}
 
 	if len(allowed) == 0 {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no valid fields to update (only enabled and settings are editable)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)})
 		return
 	}
 
diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go
index c532bc0b..3cf712c7 100644
--- a/internal/http/channel_instances.go
+++ b/internal/http/channel_instances.go
@@ -9,6 +9,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -47,15 +48,17 @@ func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
 		userID := extractUserID(r)
+		ctx := store.WithLocale(r.Context(), extractLocale(r))
 		if userID != "" {
-			ctx := store.WithUserID(r.Context(), userID)
-			r = r.WithContext(ctx)
+			ctx = store.WithUserID(ctx, userID)
 		}
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -93,7 +96,8 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ
 	instances, err := h.store.ListPaged(r.Context(), opts)
 	if err != nil {
 		slog.Error("channel_instances.list", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list instances"})
+		locale := store.LocaleFromContext(r.Context())
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "instances")})
 		return
 	}
 
@@ -113,6 +117,7 @@ func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Requ
 }
 
 func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var body struct {
 		Name        string          `json:"name"`
 		DisplayName string          `json:"display_name"`
@@ -123,23 +128,23 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re
 		Enabled     *bool           `json:"enabled"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if body.Name == "" || body.ChannelType == "" || body.AgentID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name, channel_type, and agent_id are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")})
 		return
 	}
 
 	if !isValidChannelType(body.ChannelType) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid channel_type"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidChannelType)})
 		return
 	}
 
 	agentID, err := uuid.Parse(body.AgentID)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -172,15 +177,16 @@ func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Re
 }
 
 func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
 		return
 	}
 
 	inst, err := h.store.Get(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
 		return
 	}
 
@@ -188,15 +194,16 @@ func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Reque
 }
 
 func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
@@ -211,20 +218,21 @@ func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Re
 }
 
 func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
 		return
 	}
 
 	// Look up instance to check if it's a default (seeded) instance.
 	inst, err := h.store.Get(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
 		return
 	}
 	if store.IsDefaultChannelInstance(inst.Name) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete default channel instance"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)})
 		return
 	}
 
@@ -241,18 +249,18 @@ func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Re
 // maskInstanceHTTP returns a map with credentials masked for HTTP responses.
 func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
 	result := map[string]interface{}{
-		"id":           inst.ID,
-		"name":         inst.Name,
-		"display_name": inst.DisplayName,
-		"channel_type": inst.ChannelType,
-		"agent_id":     inst.AgentID,
-		"config":       inst.Config,
-		"enabled":      inst.Enabled,
-		"is_default":       store.IsDefaultChannelInstance(inst.Name),
-		"has_credentials":  len(inst.Credentials) > 0,
-		"created_by":       inst.CreatedBy,
-		"created_at":       inst.CreatedAt,
-		"updated_at":       inst.UpdatedAt,
+		"id":              inst.ID,
+		"name":            inst.Name,
+		"display_name":    inst.DisplayName,
+		"channel_type":    inst.ChannelType,
+		"agent_id":        inst.AgentID,
+		"config":          inst.Config,
+		"enabled":         inst.Enabled,
+		"is_default":      store.IsDefaultChannelInstance(inst.Name),
+		"has_credentials": len(inst.Credentials) > 0,
+		"created_by":      inst.CreatedBy,
+		"created_at":      inst.CreatedAt,
+		"updated_at":      inst.UpdatedAt,
 	}
 
 	if len(inst.Credentials) > 0 {
@@ -277,14 +285,15 @@ func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
 
 // resolveAgentID looks up the channel instance and returns its agent_id.
 func (h *ChannelInstancesHandler) resolveAgentID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
+	locale := store.LocaleFromContext(r.Context())
 	instID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid instance ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
 		return uuid.Nil, false
 	}
 	inst, err := h.store.Get(r.Context(), instID)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "instance not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
 		return uuid.Nil, false
 	}
 	return inst.AgentID, true
@@ -298,7 +307,8 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h
 	groups, err := h.agentStore.ListGroupFileWriterGroups(r.Context(), agentID)
 	if err != nil {
 		slog.Error("channel_instances.writer_groups", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list writer groups"})
+		locale := store.LocaleFromContext(r.Context())
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")})
 		return
 	}
 	if groups == nil {
@@ -312,15 +322,16 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht
 	if !ok {
 		return
 	}
+	locale := store.LocaleFromContext(r.Context())
 	groupID := r.URL.Query().Get("group_id")
 	if groupID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id query parameter is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")})
 		return
 	}
 	writers, err := h.agentStore.ListGroupFileWriters(r.Context(), agentID, groupID)
 	if err != nil {
 		slog.Error("channel_instances.list_writers", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list writers"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")})
 		return
 	}
 	if writers == nil {
@@ -334,6 +345,7 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
 	if !ok {
 		return
 	}
+	locale := store.LocaleFromContext(r.Context())
 	var body struct {
 		GroupID     string `json:"group_id"`
 		UserID      string `json:"user_id"`
@@ -341,16 +353,16 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
 		Username    string `json:"username"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if body.GroupID == "" || body.UserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id and user_id are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")})
 		return
 	}
 	if err := h.agentStore.AddGroupFileWriter(r.Context(), agentID, body.GroupID, body.UserID, body.DisplayName, body.Username); err != nil {
 		slog.Error("channel_instances.add_writer", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to add writer"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())})
 		return
 	}
 	writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
@@ -361,15 +373,16 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h
 	if !ok {
 		return
 	}
+	locale := store.LocaleFromContext(r.Context())
 	userID := r.PathValue("userId")
 	groupID := r.URL.Query().Get("group_id")
 	if groupID == "" || userID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group_id query parameter and userId path parameter are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")})
 		return
 	}
 	if err := h.agentStore.RemoveGroupFileWriter(r.Context(), agentID, groupID, userID); err != nil {
 		slog.Error("channel_instances.remove_writer", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to remove writer"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())})
 		return
 	}
 	writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go
index 90db0e8d..15bb8dbc 100644
--- a/internal/http/chat_completions.go
+++ b/internal/http/chat_completions.go
@@ -10,6 +10,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/agent"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/sessions"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
@@ -74,14 +75,16 @@ type chatUsage struct {
 }
 
 func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
+
 	if r.Method != http.MethodPost {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		http.Error(w, i18n.T(locale, i18n.MsgMethodNotAllowed), http.StatusMethodNotAllowed)
 		return
 	}
 
 	// Auth check (timing-safe comparison)
 	if !tokenMatch(extractBearerToken(r), h.token) {
-		http.Error(w, `{"error":{"message":"Invalid authentication","type":"invalid_request_error"}}`, http.StatusUnauthorized)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"invalid_request_error"}}`, i18n.T(locale, i18n.MsgInvalidAuth)), http.StatusUnauthorized)
 		return
 	}
 
@@ -93,7 +96,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		}
 		if !h.rateLimiter(key) {
 			w.Header().Set("Retry-After", "60")
-			http.Error(w, `{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}`, http.StatusTooManyRequests)
+			http.Error(w, fmt.Sprintf(`{"error":{"message":"%s","type":"rate_limit_error"}}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
 			return
 		}
 	}
@@ -104,25 +107,25 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	var req chatCompletionsRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, fmt.Sprintf(`{"error":{"message":"Invalid JSON: %s"}}`, err), http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgInvalidRequest, err.Error())), http.StatusBadRequest)
 		return
 	}
 
 	if len(req.Messages) == 0 {
-		http.Error(w, `{"error":{"message":"messages is required"}}`, http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgMsgsRequired)), http.StatusBadRequest)
 		return
 	}
 
 	agentID := extractAgentID(r, req.Model)
 	userID := extractUserID(r)
 	if h.isManaged && userID == "" {
-		http.Error(w, `{"error":{"message":"X-GoClaw-User-Id header is required in managed mode"}}`, http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgUserIDHeader)), http.StatusBadRequest)
 		return
 	}
 
 	loop, err := h.agents.Get(agentID)
 	if err != nil {
-		http.Error(w, fmt.Sprintf(`{"error":{"message":"Agent not found: %s"}}`, agentID), http.StatusNotFound)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNotFound, "agent", agentID)), http.StatusNotFound)
 		return
 	}
 
@@ -135,12 +138,12 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		}
 	}
 	if lastMessage == "" {
-		http.Error(w, `{"error":{"message":"No user message found"}}`, http.StatusBadRequest)
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNoUserMessage)), http.StatusBadRequest)
 		return
 	}
 
-	// Inject user_id into context for downstream stores/tools
-	ctx := r.Context()
+	// Inject user_id and locale into context for downstream stores/tools
+	ctx := store.WithLocale(r.Context(), extractLocale(r))
 	if userID != "" {
 		ctx = store.WithUserID(ctx, userID)
 	}
@@ -174,7 +177,8 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http.
 	})
 
 	if err != nil {
-		http.Error(w, fmt.Sprintf(`{"error":{"message":"Agent error: %s"}}`, err), http.StatusInternalServerError)
+		locale := store.LocaleFromContext(r.Context())
+		http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgInternalError, err.Error())), http.StatusInternalServerError)
 		return
 	}
 
@@ -205,7 +209,8 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http.
 func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, sessionKey, message, model, userID string) {
 	flusher, ok := w.(http.Flusher)
 	if !ok {
-		http.Error(w, "streaming not supported", http.StatusInternalServerError)
+		locale := store.LocaleFromContext(r.Context())
+		http.Error(w, i18n.T(locale, i18n.MsgStreamingNotSupported), http.StatusInternalServerError)
 		return
 	}
 
diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go
index e85088f3..6a28abae 100644
--- a/internal/http/custom_tools.go
+++ b/internal/http/custom_tools.go
@@ -9,6 +9,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/tools"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -40,15 +41,17 @@ func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
 		userID := extractUserID(r)
+		ctx := store.WithLocale(r.Context(), extractLocale(r))
 		if userID != "" {
-			ctx := store.WithUserID(r.Context(), userID)
-			r = r.WithContext(ctx)
+			ctx = store.WithUserID(ctx, userID)
 		}
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -64,6 +67,7 @@ func (h *CustomToolsHandler) emitCacheInvalidate(key string) {
 }
 
 func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	opts := store.CustomToolListOpts{
 		Limit:  50,
 		Offset: 0,
@@ -72,7 +76,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
 	if v := r.URL.Query().Get("agent_id"); v != "" {
 		id, err := uuid.Parse(v)
 		if err != nil {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 			return
 		}
 		opts.AgentID = &id
@@ -94,7 +98,7 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
 	result, err := h.store.ListPaged(r.Context(), opts)
 	if err != nil {
 		slog.Error("custom_tools.list", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list tools"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")})
 		return
 	}
 
@@ -109,25 +113,26 @@ func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var def store.CustomToolDef
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&def); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if def.Name == "" || def.Command == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and command are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and command")})
 		return
 	}
 	if !isValidSlug(def.Name) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 		return
 	}
 
 	// Check name collision with built-in/MCP tools
 	if h.toolsReg != nil {
 		if _, exists := h.toolsReg.Get(def.Name); exists {
-			writeJSON(w, http.StatusConflict, map[string]string{"error": "tool name conflicts with existing built-in or MCP tool"})
+			writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "tool name", def.Name)})
 			return
 		}
 	}
@@ -152,15 +157,16 @@ func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request
 }
 
 func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
 		return
 	}
 
 	def, err := h.store.Get(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "tool not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", id.String())})
 		return
 	}
 
@@ -168,21 +174,22 @@ func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if name, ok := updates["name"]; ok {
 		if s, _ := name.(string); !isValidSlug(s) {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 			return
 		}
 	}
@@ -198,9 +205,10 @@ func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request
 }
 
 func (h *CustomToolsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid tool ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")})
 		return
 	}
 
diff --git a/internal/http/delegations.go b/internal/http/delegations.go
index bea5d873..49560dc2 100644
--- a/internal/http/delegations.go
+++ b/internal/http/delegations.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -28,10 +29,14 @@ func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerF
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
+		locale := extractLocale(r)
+		ctx := store.WithLocale(r.Context(), locale)
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -87,10 +92,11 @@ func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *DelegationsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	idStr := r.PathValue("id")
 	id, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "delegation")})
 		return
 	}
 
diff --git a/internal/http/files.go b/internal/http/files.go
index 32ceadb4..c88ea077 100644
--- a/internal/http/files.go
+++ b/internal/http/files.go
@@ -7,6 +7,8 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 )
 
 // FilesHandler serves files over HTTP with Bearer token auth.
@@ -34,7 +36,8 @@ func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 				provided = r.URL.Query().Get("token")
 			}
 			if provided != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -43,16 +46,17 @@ func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 }
 
 func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	urlPath := r.PathValue("path")
 	if urlPath == "" {
-		http.Error(w, "path is required", http.StatusBadRequest)
+		http.Error(w, i18n.T(locale, i18n.MsgRequired, "path"), http.StatusBadRequest)
 		return
 	}
 
 	// Prevent path traversal
 	if strings.Contains(urlPath, "..") {
 		slog.Warn("security.files_traversal", "path", urlPath)
-		http.Error(w, "invalid path", http.StatusBadRequest)
+		http.Error(w, i18n.T(locale, i18n.MsgInvalidPath), http.StatusBadRequest)
 		return
 	}
 
diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go
index 4b388711..6d3e6aaa 100644
--- a/internal/http/knowledge_graph_handlers.go
+++ b/internal/http/knowledge_graph_handlers.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"strconv"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -53,13 +54,14 @@ func (h *KnowledgeGraphHandler) handleListEntities(w http.ResponseWriter, r *htt
 }
 
 func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 	entityID := r.PathValue("entityID")
 	userID := r.URL.Query().Get("user_id")
 
 	entity, err := h.store.GetEntity(r.Context(), agentID, userID, entityID)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "entity not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "entity", entityID)})
 		return
 	}
 
@@ -78,17 +80,18 @@ func (h *KnowledgeGraphHandler) handleGetEntity(w http.ResponseWriter, r *http.R
 }
 
 func (h *KnowledgeGraphHandler) handleUpsertEntity(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 
 	var entity store.Entity
 	if err := json.NewDecoder(r.Body).Decode(&entity); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	entity.AgentID = agentID
 
 	if entity.ExternalID == "" || entity.Name == "" || entity.EntityType == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "external_id, name, and entity_type are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgEntityFieldsRequired)})
 		return
 	}
 	if entity.Confidence <= 0 {
@@ -117,6 +120,7 @@ func (h *KnowledgeGraphHandler) handleDeleteEntity(w http.ResponseWriter, r *htt
 }
 
 func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 
 	var body struct {
@@ -125,11 +129,11 @@ func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Re
 		MaxDepth int    `json:"max_depth"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if body.EntityID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "entity_id is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgEntityIDRequired)})
 		return
 	}
 	if body.MaxDepth <= 0 {
@@ -152,6 +156,7 @@ func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Re
 }
 
 func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 
 	var body struct {
@@ -162,21 +167,21 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req
 		MinConf  float64 `json:"min_confidence"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if body.Text == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTextRequired)})
 		return
 	}
 	if body.Provider == "" || body.Model == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider and model are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgProviderModelRequired)})
 		return
 	}
 
 	extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf)
 	if extractor == nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider or model"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidProviderOrModel)})
 		return
 	}
 
diff --git a/internal/http/mcp.go b/internal/http/mcp.go
index 82c3aac2..71f67a58 100644
--- a/internal/http/mcp.go
+++ b/internal/http/mcp.go
@@ -8,6 +8,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
 )
@@ -75,15 +76,17 @@ func (h *MCPHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
 		userID := extractUserID(r)
+		ctx := store.WithLocale(r.Context(), extractLocale(r))
 		if userID != "" {
-			ctx := store.WithUserID(r.Context(), userID)
-			r = r.WithContext(ctx)
+			ctx = store.WithUserID(ctx, userID)
 		}
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -94,25 +97,27 @@ func (h *MCPHandler) handleListServers(w http.ResponseWriter, r *http.Request) {
 	servers, err := h.store.ListServers(r.Context())
 	if err != nil {
 		slog.Error("mcp.list_servers", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list servers"})
+		locale := store.LocaleFromContext(r.Context())
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "servers")})
 		return
 	}
 	writeJSON(w, http.StatusOK, map[string]interface{}{"servers": servers})
 }
 
 func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var srv store.MCPServerData
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&srv); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if srv.Name == "" || srv.Transport == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and transport are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and transport")})
 		return
 	}
 	if !isValidSlug(srv.Name) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 		return
 	}
 
@@ -132,15 +137,16 @@ func (h *MCPHandler) handleCreateServer(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *MCPHandler) handleGetServer(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
 	srv, err := h.store.GetServer(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "server", id.String())})
 		return
 	}
 
@@ -148,21 +154,22 @@ func (h *MCPHandler) handleGetServer(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if name, ok := updates["name"]; ok {
 		if s, _ := name.(string); !isValidSlug(s) {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 			return
 		}
 	}
@@ -178,9 +185,10 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
@@ -193,4 +201,3 @@ func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request)
 	h.emitCacheInvalidate()
 	writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
 }
-
diff --git a/internal/http/mcp_grants.go b/internal/http/mcp_grants.go
index d8409d80..bf492d94 100644
--- a/internal/http/mcp_grants.go
+++ b/internal/http/mcp_grants.go
@@ -7,13 +7,15 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	serverID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
@@ -23,13 +25,13 @@ func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
 		ToolDeny  json.RawMessage `json:"tool_deny,omitempty"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	agentID, err := uuid.Parse(req.AgentID)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -53,15 +55,16 @@ func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	serverID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
 	agentID, err := uuid.Parse(r.PathValue("agentID"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -76,9 +79,10 @@ func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	agentID, err := uuid.Parse(r.PathValue("agentID"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -93,16 +97,17 @@ func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Reques
 }
 
 func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	serverID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
 	grants, err := h.store.ListServerGrants(r.Context(), serverID)
 	if err != nil {
 		slog.Error("mcp.list_server_grants", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list grants"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "grants")})
 		return
 	}
 
@@ -110,9 +115,10 @@ func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Reque
 }
 
 func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	serverID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
@@ -122,12 +128,12 @@ func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
 		ToolDeny  json.RawMessage `json:"tool_deny,omitempty"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if req.UserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
 		return
 	}
 	if err := store.ValidateUserID(req.UserID); err != nil {
@@ -155,9 +161,10 @@ func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *MCPHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	serverID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
diff --git a/internal/http/mcp_requests.go b/internal/http/mcp_requests.go
index 4a193258..e0eb7e29 100644
--- a/internal/http/mcp_requests.go
+++ b/internal/http/mcp_requests.go
@@ -7,22 +7,24 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 func (h *MCPHandler) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var req store.MCPAccessRequest
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if req.ServerID == uuid.Nil || req.Scope == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server_id and scope are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "server_id and scope")})
 		return
 	}
 	if req.Scope != "agent" && req.Scope != "user" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be 'agent' or 'user'"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "scope must be 'agent' or 'user'")})
 		return
 	}
 
@@ -50,9 +52,10 @@ func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Re
 }
 
 func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	requestID, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "request")})
 		return
 	}
 
@@ -61,7 +64,7 @@ func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request)
 		Note     string `json:"note,omitempty"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
diff --git a/internal/http/mcp_tools.go b/internal/http/mcp_tools.go
index a0202f27..e9f92f21 100644
--- a/internal/http/mcp_tools.go
+++ b/internal/http/mcp_tools.go
@@ -7,11 +7,14 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 	mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
 )
 
 // handleTestConnection tests an MCP server connection without saving it.
 func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var req struct {
 		Transport string            `json:"transport"`
 		Command   string            `json:"command"`
@@ -21,11 +24,11 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
 		Env       map[string]string `json:"env"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if req.Transport == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "transport is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "transport")})
 		return
 	}
 
@@ -46,15 +49,16 @@ func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
 
 // handleListServerTools lists tools for a specific MCP server.
 func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
 		return
 	}
 
 	srv, err := h.store.GetServer(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "server", id.String())})
 		return
 	}
 
diff --git a/internal/http/media_serve.go b/internal/http/media_serve.go
index 74ee3d61..1cade70a 100644
--- a/internal/http/media_serve.go
+++ b/internal/http/media_serve.go
@@ -7,6 +7,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/media"
 )
 
@@ -34,7 +35,8 @@ func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 				provided = r.URL.Query().Get("token")
 			}
 			if provided != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -43,9 +45,10 @@ func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 }
 
 func (h *MediaServeHandler) handleServe(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id := r.PathValue("id")
 	if id == "" || strings.Contains(id, "..") || strings.Contains(id, "/") {
-		http.Error(w, "invalid media id", http.StatusBadRequest)
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid media id")})
 		return
 	}
 
diff --git a/internal/http/media_upload.go b/internal/http/media_upload.go
index 3141e876..32483dd8 100644
--- a/internal/http/media_upload.go
+++ b/internal/http/media_upload.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/nextlevelbuilder/goclaw/internal/channels/media"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 )
 
 const (
@@ -37,7 +38,8 @@ func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 		if h.token != "" {
 			provided := extractBearerToken(r)
 			if provided != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -46,16 +48,17 @@ func (h *MediaUploadHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 }
 
 func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
 
 	if err := r.ParseMultipartForm(maxUploadSize); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file too large or invalid multipart form"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)})
 		return
 	}
 
 	file, header, err := r.FormFile("file")
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)})
 		return
 	}
 	defer file.Close()
@@ -63,7 +66,7 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request
 	// Sanitize filename: strip path, prevent traversal.
 	origName := filepath.Base(header.Filename)
 	if origName == "." || origName == "/" || strings.Contains(origName, "..") {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)})
 		return
 	}
 
@@ -78,14 +81,14 @@ func (h *MediaUploadHandler) handleUpload(w http.ResponseWriter, r *http.Request
 
 	out, err := os.Create(tmpPath)
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create temp file")})
 		return
 	}
 	defer out.Close()
 
 	if _, err := io.Copy(out, file); err != nil {
 		os.Remove(tmpPath)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save file")})
 		return
 	}
 
diff --git a/internal/http/memory.go b/internal/http/memory.go
index 79fdfd10..3f5a88d4 100644
--- a/internal/http/memory.go
+++ b/internal/http/memory.go
@@ -3,6 +3,7 @@ package http
 import (
 	"net/http"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -34,7 +35,8 @@ func (h *MemoryHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
diff --git a/internal/http/memory_handlers.go b/internal/http/memory_handlers.go
index 2b6d63ed..ee2c287a 100644
--- a/internal/http/memory_handlers.go
+++ b/internal/http/memory_handlers.go
@@ -5,6 +5,7 @@ import (
 	"log/slog"
 	"net/http"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -44,6 +45,7 @@ func (h *MemoryHandler) handleListDocuments(w http.ResponseWriter, r *http.Reque
 }
 
 func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 	path := r.PathValue("path")
 	userID := r.URL.Query().Get("user_id")
@@ -51,13 +53,14 @@ func (h *MemoryHandler) handleGetDocument(w http.ResponseWriter, r *http.Request
 	detail, err := h.store.GetDocumentDetail(r.Context(), agentID, userID, path)
 	if err != nil {
 		slog.Warn("memory.get_document failed", "error", err, "path", path)
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "document", path)})
 		return
 	}
 	writeJSON(w, http.StatusOK, detail)
 }
 
 func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 	path := r.PathValue("path")
 
@@ -66,7 +69,7 @@ func (h *MemoryHandler) handlePutDocument(w http.ResponseWriter, r *http.Request
 		UserID  string `json:"user_id"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
@@ -92,12 +95,13 @@ func (h *MemoryHandler) handleDeleteDocument(w http.ResponseWriter, r *http.Requ
 }
 
 func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 	path := r.URL.Query().Get("path")
 	userID := r.URL.Query().Get("user_id")
 
 	if path == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path query parameter required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
 		return
 	}
 
@@ -114,6 +118,7 @@ func (h *MemoryHandler) handleListChunks(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 
 	var body struct {
@@ -121,11 +126,11 @@ func (h *MemoryHandler) handleIndexDocument(w http.ResponseWriter, r *http.Reque
 		UserID string `json:"user_id"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if body.Path == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
 		return
 	}
 
@@ -154,6 +159,7 @@ func (h *MemoryHandler) handleIndexAll(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	agentID := r.PathValue("agentID")
 
 	var body struct {
@@ -163,11 +169,11 @@ func (h *MemoryHandler) handleSearch(w http.ResponseWriter, r *http.Request) {
 		MinScore   float64 `json:"min_score"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if body.Query == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "query is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "query")})
 		return
 	}
 
diff --git a/internal/http/oauth.go b/internal/http/oauth.go
index e160d4cb..fa13d5e8 100644
--- a/internal/http/oauth.go
+++ b/internal/http/oauth.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/oauth"
 	"github.com/nextlevelbuilder/goclaw/internal/providers"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -47,7 +48,8 @@ func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) {
 func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if !tokenMatch(extractBearerToken(r), h.token) {
-			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+			locale := extractLocale(r)
+			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 			return
 		}
 		next(w, r)
@@ -80,6 +82,7 @@ func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	h.mu.Lock()
 	defer h.mu.Unlock()
 
@@ -102,7 +105,7 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		slog.Error("oauth.start", "error", err)
 		writeJSON(w, http.StatusInternalServerError, map[string]string{
-			"error": "failed to start OAuth flow (is port 1455 available?)",
+			"error": i18n.T(locale, i18n.MsgInternalError, "failed to start OAuth flow (is port 1455 available?)"),
 		})
 		return
 	}
@@ -142,11 +145,12 @@ func (h *OAuthHandler) waitForCallback(pending *oauth.PendingLogin) {
 }
 
 func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	var body struct {
 		RedirectURL string `json:"redirect_url"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RedirectURL == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "redirect_url is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "redirect_url")})
 		return
 	}
 
@@ -155,7 +159,7 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque
 	h.mu.Unlock()
 
 	if pending == nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no pending OAuth flow"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoPendingOAuth)})
 		return
 	}
 
@@ -177,7 +181,7 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque
 	providerID, err := h.saveAndRegister(r.Context(), tokenResp)
 	if err != nil {
 		slog.Error("oauth.save_token", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save token"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToSaveToken)})
 		return
 	}
 
diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go
index e9ab803b..17aeb9de 100644
--- a/internal/http/pending_messages.go
+++ b/internal/http/pending_messages.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"net/http"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -28,10 +29,14 @@ func (h *PendingMessagesHandler) authMiddleware(next http.HandlerFunc) http.Hand
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
+		locale := extractLocale(r)
+		ctx := store.WithLocale(r.Context(), locale)
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -58,10 +63,11 @@ func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http
 
 // GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group
 func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	channel := r.URL.Query().Get("channel")
 	key := r.URL.Query().Get("key")
 	if channel == "" || key == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel and key are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
 		return
 	}
 
@@ -75,10 +81,11 @@ func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *ht
 
 // DELETE /v1/pending-messages?channel=X&key=Y — clear a group
 func (h *PendingMessagesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	channel := r.URL.Query().Get("channel")
 	key := r.URL.Query().Get("key")
 	if channel == "" || key == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel and key are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
 		return
 	}
 
@@ -96,13 +103,14 @@ type compactRequest struct {
 
 // POST /v1/pending-messages/compact — MVP: clear group and return success
 func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	var req compactRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if req.ChannelName == "" || req.HistoryKey == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "channel_name and history_key are required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "channel_name and history_key")})
 		return
 	}
 
diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go
index 81e88533..55e2c8b7 100644
--- a/internal/http/provider_models.go
+++ b/internal/http/provider_models.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -25,15 +26,16 @@ type ModelInfo struct {
 //
 //	GET /v1/providers/{id}/models
 func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
 		return
 	}
 
 	p, err := h.store.GetProvider(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
 		return
 	}
 
@@ -44,7 +46,7 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
 	}
 
 	if p.APIKey == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")})
 		return
 	}
 
diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go
index d91d2911..7b3cb4af 100644
--- a/internal/http/provider_verify.go
+++ b/internal/http/provider_verify.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/providers"
 )
 
@@ -17,9 +18,10 @@ import (
 //	Body: {"model": "anthropic/claude-sonnet-4"}
 //	Response: {"valid": true} or {"valid": false, "error": "..."}
 func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
 		return
 	}
 
@@ -27,18 +29,18 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
 		Model string `json:"model"`
 	}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if req.Model == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "model")})
 		return
 	}
 
 	// Look up provider record from DB to get the provider name
 	p, err := h.store.GetProvider(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
 		return
 	}
 
diff --git a/internal/http/providers.go b/internal/http/providers.go
index 809d9b5a..20d7b005 100644
--- a/internal/http/providers.go
+++ b/internal/http/providers.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/oauth"
 	"github.com/nextlevelbuilder/goclaw/internal/providers"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -58,7 +59,8 @@ func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -128,7 +130,8 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re
 	providers, err := h.store.ListProviders(r.Context())
 	if err != nil {
 		slog.Error("providers.list", "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list providers"})
+		locale := extractLocale(r)
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "providers")})
 		return
 	}
 
@@ -140,22 +143,23 @@ func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Re
 }
 
 func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	var p store.LLMProviderData
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&p); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if p.Name == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
 		return
 	}
 	if !isValidSlug(p.Name) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug (lowercase letters, numbers, hyphens only)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 		return
 	}
 	if !store.ValidProviderTypes[p.ProviderType] {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
 		return
 	}
 
@@ -169,7 +173,7 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
 		for _, ep := range existing {
 			if ep.ProviderType == store.ProviderClaudeCLI {
 				writeJSON(w, http.StatusConflict, map[string]string{
-					"error": "A Claude CLI provider already exists. Only one is allowed per instance.",
+					"error": i18n.T(locale, i18n.MsgAlreadyExists, "Claude CLI provider", "only one is allowed per instance"),
 				})
 				return
 			}
@@ -190,15 +194,16 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R
 }
 
 func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
 		return
 	}
 
 	p, err := h.store.GetProvider(r.Context(), id)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
 		return
 	}
 
@@ -207,22 +212,23 @@ func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Requ
 }
 
 func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	// Validate name if being updated
 	if name, ok := updates["name"]; ok {
 		if s, _ := name.(string); !isValidSlug(s) {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be a valid slug"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
 			return
 		}
 	}
@@ -233,7 +239,7 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
 	// silently deleting it would hide the error from the end user.
 	if pt, ok := updates["provider_type"]; ok {
 		if s, _ := pt.(string); !store.ValidProviderTypes[s] {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
 			return
 		}
 	}
@@ -282,9 +288,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
 }
 
 func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
 		return
 	}
 
diff --git a/internal/http/skills.go b/internal/http/skills.go
index aef302ce..28da40fc 100644
--- a/internal/http/skills.go
+++ b/internal/http/skills.go
@@ -8,6 +8,7 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/store/pg"
 	"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -62,15 +63,17 @@ func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
 		userID := extractUserID(r)
+		ctx := store.WithLocale(r.Context(), extractLocale(r))
 		if userID != "" {
-			ctx := store.WithUserID(r.Context(), userID)
-			r = r.WithContext(ctx)
+			ctx = store.WithUserID(ctx, userID)
 		}
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -81,26 +84,28 @@ func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id := r.PathValue("id")
 	skill, ok := h.skills.GetSkill(id)
 	if !ok {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id)})
 		return
 	}
 	writeJSON(w, http.StatusOK, skill)
 }
 
 func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	idStr := r.PathValue("id")
 	id, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
 	var updates map[string]interface{}
 	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	// Prevent changing sensitive fields
@@ -118,10 +123,11 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	idStr := r.PathValue("id")
 	id, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
@@ -133,4 +139,3 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
 	h.skills.BumpVersion()
 	writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
 }
-
diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go
index 76bc4082..63bbd8d8 100644
--- a/internal/http/skills_grants.go
+++ b/internal/http/skills_grants.go
@@ -12,21 +12,23 @@ import (
 	"github.com/google/uuid"
 
 	"github.com/nextlevelbuilder/goclaw/internal/bus"
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	agentIDStr := r.PathValue("agentID")
 	agentID, err := uuid.Parse(agentIDStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
 	skills, err := h.skills.ListWithGrantStatus(r.Context(), agentID)
 	if err != nil {
 		slog.Error("failed to list skills with grant status", "agent_id", agentID, "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list skills"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "skills")})
 		return
 	}
 
@@ -34,11 +36,12 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req
 }
 
 func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	userID := store.UserIDFromContext(r.Context())
 	idStr := r.PathValue("id")
 	skillID, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
@@ -47,13 +50,13 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
 		Version int    `json:"version"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	agentID, err := uuid.Parse(req.AgentID)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -72,17 +75,18 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	idStr := r.PathValue("id")
 	skillID, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
 	agentIDStr := r.PathValue("agentID")
 	agentID, err := uuid.Parse(agentIDStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
 		return
 	}
 
@@ -97,11 +101,12 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request
 }
 
 func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	userID := store.UserIDFromContext(r.Context())
 	idStr := r.PathValue("id")
 	skillID, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
@@ -109,11 +114,11 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
 		UserID string `json:"user_id"`
 	}
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 	if req.UserID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")})
 		return
 	}
 	if err := store.ValidateUserID(req.UserID); err != nil {
@@ -132,10 +137,11 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request)
 }
 
 func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	idStr := r.PathValue("id")
 	skillID, err := uuid.Parse(idStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go
index e87a2fe9..b7a918c7 100644
--- a/internal/http/skills_upload.go
+++ b/internal/http/skills_upload.go
@@ -11,15 +11,17 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/store/pg"
 )
 
 // handleUpload processes a ZIP file upload containing a skill (must have SKILL.md at root).
 func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	userID := store.UserIDFromContext(r.Context())
 	if userID == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
 		return
 	}
 
@@ -27,7 +29,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 
 	file, header, err := r.FormFile("file")
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required: " + err.Error()})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "file is required: "+err.Error())})
 		return
 	}
 	defer file.Close()
@@ -35,7 +37,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 	// Save to temp file for zip processing
 	tmp, err := os.CreateTemp("", "skill-upload-*.zip")
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create temp file")})
 		return
 	}
 	defer os.Remove(tmp.Name())
@@ -44,7 +46,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 	hasher := sha256.New()
 	size, err := io.Copy(io.MultiWriter(tmp, hasher), file)
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save upload"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")})
 		return
 	}
 	fileHash := fmt.Sprintf("%x", hasher.Sum(nil))
@@ -52,7 +54,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 	// Open as zip
 	zr, err := zip.OpenReader(tmp.Name())
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ZIP file"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "invalid ZIP file")})
 		return
 	}
 	defer zr.Close()
@@ -77,27 +79,27 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 	if skillMD == nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ZIP must contain SKILL.md at root (or inside a single top-level directory)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "ZIP must contain SKILL.md at root (or inside a single top-level directory)")})
 		return
 	}
 
 	// Read and parse SKILL.md frontmatter
 	skillContent, err := readZipFile(skillMD)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read SKILL.md"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "failed to read SKILL.md")})
 		return
 	}
 
 	name, description, slug, frontmatter := parseSkillFrontmatter(skillContent)
 	if name == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "SKILL.md must have a name in frontmatter"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name in SKILL.md frontmatter")})
 		return
 	}
 	if slug == "" {
 		slug = slugify(name)
 	}
 	if !slugRegexp.MatchString(slug) {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug must be a valid slug (lowercase letters, numbers, hyphens only)"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "slug")})
 		return
 	}
 
@@ -107,7 +109,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 	// Extract to filesystem: baseDir/slug/version/
 	destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version))
 	if err := os.MkdirAll(destDir, 0755); err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create skill directory"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill directory")})
 		return
 	}
 
@@ -167,7 +169,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
 
 	id, err := h.skills.CreateSkillManaged(r.Context(), skill)
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save skill: " + err.Error()})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "skill", err.Error())})
 		return
 	}
 
diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go
index a295f8f4..a2a48cf8 100644
--- a/internal/http/skills_versions.go
+++ b/internal/http/skills_versions.go
@@ -10,19 +10,23 @@ import (
 	"strings"
 
 	"github.com/google/uuid"
+
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
+	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
 // handleListVersions returns all available version numbers for a skill.
 func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
 	_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
 	if !ok {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
 		return
 	}
 
@@ -60,15 +64,16 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques
 
 // handleListFiles returns all files in a skill version directory.
 func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
 	_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
 	if !ok {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
 		return
 	}
 
@@ -76,7 +81,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
 	if v := r.URL.Query().Get("version"); v != "" {
 		parsed, err := strconv.Atoi(v)
 		if err != nil || parsed < 1 {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
 			return
 		}
 		version = parsed
@@ -84,7 +89,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
 
 	versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version))
 	if _, err := os.Stat(versionDir); err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
 		return
 	}
 
@@ -137,26 +142,27 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request)
 
 // handleReadFile reads a single file from a skill version directory.
 func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	id, err := uuid.Parse(r.PathValue("id"))
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
 		return
 	}
 
 	relPath := r.PathValue("path")
 	if relPath == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
 		return
 	}
 	if strings.Contains(relPath, "..") {
 		slog.Warn("security.skill_files_traversal", "path", relPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	_, slug, currentVersion, ok := h.skills.GetSkillFilePath(id)
 	if !ok {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
 		return
 	}
 
@@ -164,7 +170,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
 	if v := r.URL.Query().Get("version"); v != "" {
 		parsed, err := strconv.Atoi(v)
 		if err != nil || parsed < 1 {
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVersion)})
 			return
 		}
 		version = parsed
@@ -176,31 +182,31 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
 	// Verify resolved path is within the version directory
 	if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) {
 		slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	// Use Lstat to detect symlinks — reject them to prevent directory escape
 	info, err := os.Lstat(absPath)
 	if err != nil || info.IsDir() {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
 		return
 	}
 	if info.Mode()&os.ModeSymlink != 0 {
 		slog.Warn("security.skill_files_symlink", "path", absPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	// Skip system artifacts
 	if isSystemArtifact(relPath) {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
 		return
 	}
 
 	data, err := os.ReadFile(absPath)
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToReadFile)})
 		return
 	}
 
diff --git a/internal/http/storage.go b/internal/http/storage.go
index dab3109b..969d8930 100644
--- a/internal/http/storage.go
+++ b/internal/http/storage.go
@@ -6,6 +6,8 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 )
 
 // StorageHandler provides HTTP endpoints for browsing and managing
@@ -33,7 +35,8 @@ func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc {
 		if h.token != "" {
 			provided := extractBearerToken(r)
 			if !tokenMatch(provided, h.token) {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
@@ -65,10 +68,11 @@ func isProtectedPath(rel string) bool {
 // handleList lists all files and directories under ~/.goclaw/.
 // Optional query param ?path= scopes the listing to a subtree.
 func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	subPath := r.URL.Query().Get("path")
 	if strings.Contains(subPath, "..") {
 		slog.Warn("security.storage_traversal", "path", subPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
@@ -77,7 +81,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
 		rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath))
 		if !strings.HasPrefix(rootDir, h.baseDir) {
 			slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir)
-			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+			writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 			return
 		}
 	}
@@ -87,8 +91,8 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
 		Name      string `json:"name"`
 		IsDir     bool   `json:"isDir"`
 		Size      int64  `json:"size"`
-		TotalSize int64  `json:"totalSize"`  // recursive size for directories
-		Protected bool   `json:"protected"`  // true if deletion is blocked
+		TotalSize int64  `json:"totalSize"` // recursive size for directories
+		Protected bool   `json:"protected"` // true if deletion is blocked
 	}
 
 	// Compute directory sizes via a two-pass approach:
@@ -180,38 +184,39 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) {
 
 // handleRead reads a single file's content by relative path.
 func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	relPath := r.PathValue("path")
 	if relPath == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
 		return
 	}
 	if strings.Contains(relPath, "..") {
 		slog.Warn("security.storage_traversal", "path", relPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
 	if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
 		slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	info, err := os.Lstat(absPath)
 	if err != nil || info.IsDir() {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
 		return
 	}
 	if info.Mode()&os.ModeSymlink != 0 {
 		slog.Warn("security.storage_symlink", "path", absPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	data, err := os.ReadFile(absPath)
 	if err != nil {
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToReadFile)})
 		return
 	}
 
@@ -225,33 +230,34 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) {
 // handleDelete removes a file or directory (recursively).
 // Rejects deletion of the root dir and any path inside excluded directories.
 func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
 	relPath := r.PathValue("path")
 	if relPath == "" {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
 		return
 	}
 	if strings.Contains(relPath, "..") {
 		slog.Warn("security.storage_traversal", "path", relPath)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	if isProtectedPath(relPath) {
-		writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete skills directories"})
+		writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteSkillsDir)})
 		return
 	}
 
 	absPath := filepath.Join(h.baseDir, filepath.Clean(relPath))
 	if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) {
 		slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir)
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
 		return
 	}
 
 	// Verify path exists
 	info, err := os.Lstat(absPath)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "path", relPath)})
 		return
 	}
 
@@ -266,7 +272,7 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
 
 	if err != nil {
 		slog.Error("storage.delete_failed", "path", absPath, "error", err)
-		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete"})
+		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDeleteFile)})
 		return
 	}
 
diff --git a/internal/http/tools_invoke.go b/internal/http/tools_invoke.go
index a9530612..daaa7de9 100644
--- a/internal/http/tools_invoke.go
+++ b/internal/http/tools_invoke.go
@@ -2,10 +2,10 @@ package http
 
 import (
 	"encoding/json"
-	"fmt"
 	"log/slog"
 	"net/http"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 	"github.com/nextlevelbuilder/goclaw/internal/tools"
 )
@@ -39,26 +39,28 @@ type toolsInvokeRequest struct {
 }
 
 func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	locale := extractLocale(r)
+
 	if r.Method != http.MethodPost {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": i18n.T(locale, i18n.MsgMethodNotAllowed)})
 		return
 	}
 
 	if h.token != "" {
 		if extractBearerToken(r) != h.token {
-			http.Error(w, `{"error":{"code":"UNAUTHORIZED","message":"Invalid token"}}`, http.StatusUnauthorized)
+			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 			return
 		}
 	}
 
 	var req toolsInvokeRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, fmt.Sprintf(`{"error":{"code":"BAD_REQUEST","message":"%s"}}`, err), http.StatusBadRequest)
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
 		return
 	}
 
 	if req.Tool == "" {
-		http.Error(w, `{"error":{"code":"BAD_REQUEST","message":"tool is required"}}`, http.StatusBadRequest)
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tool")})
 		return
 	}
 
@@ -68,7 +70,7 @@ func (h *ToolsInvokeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		// Just check if tool exists and return its schema
 		tool, ok := h.registry.Get(req.Tool)
 		if !ok {
-			writeToolError(w, http.StatusNotFound, "NOT_FOUND", fmt.Sprintf("Tool '%s' not found", req.Tool))
+			writeToolError(w, http.StatusNotFound, "NOT_FOUND", i18n.T(locale, i18n.MsgNotFound, "tool", req.Tool))
 			return
 		}
 
diff --git a/internal/http/traces.go b/internal/http/traces.go
index 11684b1e..d9fdb74e 100644
--- a/internal/http/traces.go
+++ b/internal/http/traces.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/google/uuid"
 
+	"github.com/nextlevelbuilder/goclaw/internal/i18n"
 	"github.com/nextlevelbuilder/goclaw/internal/store"
 )
 
@@ -30,10 +31,14 @@ func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		if h.token != "" {
 			if extractBearerToken(r) != h.token {
-				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+				locale := extractLocale(r)
+				writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
 				return
 			}
 		}
+		locale := extractLocale(r)
+		ctx := store.WithLocale(r.Context(), locale)
+		r = r.WithContext(ctx)
 		next(w, r)
 	}
 }
@@ -87,16 +92,17 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
+	locale := store.LocaleFromContext(r.Context())
 	traceIDStr := r.PathValue("traceID")
 	traceID, err := uuid.Parse(traceIDStr)
 	if err != nil {
-		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid trace ID"})
+		writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "trace")})
 		return
 	}
 
 	trace, err := h.tracing.GetTrace(r.Context(), traceID)
 	if err != nil {
-		writeJSON(w, http.StatusNotFound, map[string]string{"error": "trace not found"})
+		writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceIDStr)})
 		return
 	}
 
diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go
new file mode 100644
index 00000000..8d6e614c
--- /dev/null
+++ b/internal/i18n/catalog_en.go
@@ -0,0 +1,132 @@
+package i18n
+
+func init() {
+	register(LocaleEN, map[string]string{
+		// Common validation
+		MsgRequired:         "%s is required",
+		MsgInvalidID:        "invalid %s ID",
+		MsgNotFound:         "%s not found: %s",
+		MsgAlreadyExists:    "%s already exists: %s",
+		MsgInvalidRequest:   "invalid request: %s",
+		MsgInvalidJSON:      "invalid JSON",
+		MsgUnauthorized:     "unauthorized",
+		MsgPermissionDenied: "permission denied: insufficient role for %s",
+		MsgInternalError:    "internal error: %s",
+		MsgInvalidSlug:      "%s must be a valid slug (lowercase letters, numbers, hyphens only)",
+		MsgFailedToList:     "failed to list %s",
+		MsgFailedToCreate:   "failed to create %s: %s",
+		MsgFailedToUpdate:   "failed to update %s: %s",
+		MsgFailedToDelete:   "failed to delete %s: %s",
+		MsgFailedToSave:     "failed to save %s: %s",
+		MsgInvalidUpdates:   "invalid updates",
+
+		// Agent
+		MsgAgentNotFound:       "agent not found: %s",
+		MsgCannotDeleteDefault: "cannot delete the default agent",
+		MsgUserCtxRequired:     "user context required",
+
+		// Chat
+		MsgRateLimitExceeded: "rate limit exceeded — please wait",
+		MsgNoUserMessage:     "no user message found",
+		MsgUserIDRequired:    "user_id is required",
+		MsgMsgRequired:       "message is required",
+
+		// Channel instances
+		MsgInvalidChannelType: "invalid channel_type",
+		MsgInstanceNotFound:   "instance not found",
+
+		// Cron
+		MsgJobNotFound:     "job not found",
+		MsgInvalidCronExpr: "invalid cron expression: %s",
+
+		// Config
+		MsgConfigHashMismatch: "config has changed (hash mismatch)",
+
+		// Exec approval
+		MsgExecApprovalDisabled: "exec approval is not enabled",
+
+		// Pairing
+		MsgSenderChannelRequired: "senderId and channel are required",
+		MsgCodeRequired:          "code is required",
+		MsgSenderIDRequired:      "sender_id is required",
+
+		// HTTP API
+		MsgInvalidAuth:           "invalid authentication",
+		MsgMsgsRequired:          "messages is required",
+		MsgUserIDHeader:          "X-GoClaw-User-Id header is required in managed mode",
+		MsgFileTooLarge:          "file too large or invalid multipart form",
+		MsgMissingFileField:      "missing 'file' field",
+		MsgInvalidFilename:       "invalid filename",
+		MsgChannelKeyReq:         "channel and key are required",
+		MsgMethodNotAllowed:      "method not allowed",
+		MsgStreamingNotSupported: "streaming not supported",
+		MsgOwnerOnly:             "only owner can %s",
+		MsgNoAccess:              "no access to this %s",
+		MsgAlreadySummoning:      "agent is already being summoned",
+		MsgSummoningUnavailable:  "summoning not available",
+		MsgNoDescription:         "agent has no description to resummon from",
+		MsgInvalidPath:           "invalid path",
+
+		// Scheduler
+		MsgQueueFull:    "session queue is full",
+		MsgShuttingDown: "gateway is shutting down, please retry shortly",
+
+		// Provider
+		MsgProviderReqFailed: "%s: request failed: %s",
+
+		// Unknown method
+		MsgUnknownMethod: "unknown method: %s",
+
+		// Not implemented
+		MsgNotImplemented: "%s not yet implemented",
+
+		// Agent links
+		MsgLinksNotConfigured:   "agent links not configured",
+		MsgInvalidDirection:     "direction must be outbound, inbound, or bidirectional",
+		MsgSourceTargetSame:     "source and target must be different agents",
+		MsgCannotDelegateOpen:   "cannot delegate to open agents — only predefined agents can be delegation targets",
+		MsgNoUpdatesProvided:    "no updates provided",
+		MsgInvalidLinkStatus:    "status must be active or disabled",
+
+		// Teams
+		MsgTeamsNotConfigured:   "teams not configured",
+		MsgAgentIsTeamLead:      "agent is already the team lead",
+		MsgCannotRemoveTeamLead: "cannot remove the team lead",
+
+		// Delegations
+		MsgDelegationsUnavailable: "delegations not available",
+
+		// Channels
+		MsgCannotDeleteDefaultInst: "cannot delete default channel instance",
+
+		// Skills
+		MsgSkillsUpdateNotSupported: "skills.update not supported for file-based skills",
+		MsgCannotResolveSkillID:     "cannot resolve skill ID for file-based skill",
+
+		// Logs
+		MsgInvalidLogAction: "action must be 'start' or 'stop'",
+
+		// Config
+		MsgRawConfigRequired: "raw config is required",
+		MsgRawPatchRequired:  "raw patch is required",
+
+		// Storage / File
+		MsgCannotDeleteSkillsDir: "cannot delete skills directories",
+		MsgFailedToReadFile:      "failed to read file",
+		MsgFileNotFound:          "file not found",
+		MsgInvalidVersion:        "invalid version",
+		MsgVersionNotFound:       "version not found",
+		MsgFailedToDeleteFile:    "failed to delete",
+
+		// OAuth
+		MsgNoPendingOAuth:    "no pending OAuth flow",
+		MsgFailedToSaveToken: "failed to save token",
+
+		// Knowledge Graph
+		MsgEntityIDRequired:       "entity_id is required",
+		MsgEntityFieldsRequired:   "external_id, name, and entity_type are required",
+		MsgTextRequired:           "text is required",
+		MsgProviderModelRequired:  "provider and model are required",
+		MsgInvalidProviderOrModel: "invalid provider or model",
+	})
+}
diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go
new file mode 100644
index 00000000..533087ff
--- /dev/null
+++ b/internal/i18n/catalog_vi.go
@@ -0,0 +1,132 @@
+package i18n
+
+func init() {
+	register(LocaleVI, map[string]string{
+		// Common validation
+		MsgRequired:         "%s là bắt buộc",
+		MsgInvalidID:        "ID %s không hợp lệ",
+		MsgNotFound:         "không tìm thấy %s: %s",
+		MsgAlreadyExists:    "%s đã tồn tại: %s",
+		MsgInvalidRequest:   "yêu cầu không hợp lệ: %s",
+		MsgInvalidJSON:      "JSON không hợp lệ",
+		MsgUnauthorized:     "chưa xác thực",
+		MsgPermissionDenied: "từ chối quyền truy cập: không đủ quyền cho %s",
+		MsgInternalError:    "lỗi nội bộ: %s",
+		MsgInvalidSlug:      "%s phải là slug hợp lệ (chữ thường, số, dấu gạch ngang)",
+		MsgFailedToList:     "không thể liệt kê %s",
+		MsgFailedToCreate:   "không thể tạo %s: %s",
+		MsgFailedToUpdate:   "không thể cập nhật %s: %s",
+		MsgFailedToDelete:   "không thể xóa %s: %s",
+		MsgFailedToSave:     "không thể lưu %s: %s",
+		MsgInvalidUpdates:   "cập nhật không hợp lệ",
+
+		// Agent
+		MsgAgentNotFound:       "không tìm thấy agent: %s",
+		MsgCannotDeleteDefault: "không thể xóa agent mặc định",
+		MsgUserCtxRequired:     "yêu cầu ngữ cảnh người dùng",
+
+		// Chat
+		MsgRateLimitExceeded: "vượt quá giới hạn tốc độ — vui lòng đợi",
+		MsgNoUserMessage:     "không tìm thấy tin nhắn người dùng",
+		MsgUserIDRequired:    "user_id là bắt buộc",
+		MsgMsgRequired:       "tin nhắn là bắt buộc",
+
+		// Channel instances
+		MsgInvalidChannelType: "loại channel không hợp lệ",
+		MsgInstanceNotFound:   "không tìm thấy phiên bản",
+
+		// Cron
+		MsgJobNotFound:     "không tìm thấy tác vụ",
+		MsgInvalidCronExpr: "biểu thức cron không hợp lệ: %s",
+
+		// Config
+		MsgConfigHashMismatch: "cấu hình đã thay đổi (hash không khớp)",
+
+		// Exec approval
+		MsgExecApprovalDisabled: "phê duyệt thực thi chưa được bật",
+
+		// Pairing
+		MsgSenderChannelRequired: "senderId và channel là bắt buộc",
+		MsgCodeRequired:          "mã là bắt buộc",
+		MsgSenderIDRequired:      "sender_id là bắt buộc",
+
+		// HTTP API
+		MsgInvalidAuth:           "xác thực không hợp lệ",
+		MsgMsgsRequired:          "messages là bắt buộc",
+		MsgUserIDHeader:          "header X-GoClaw-User-Id là bắt buộc ở chế độ managed",
+		MsgFileTooLarge:          "tệp quá lớn hoặc form multipart không hợp lệ",
+		MsgMissingFileField:      "thiếu trường 'file'",
+		MsgInvalidFilename:       "tên tệp không hợp lệ",
+		MsgChannelKeyReq:         "channel và key là bắt buộc",
+		MsgMethodNotAllowed:      "phương thức không được phép",
+		MsgStreamingNotSupported: "streaming không được hỗ trợ",
+		MsgOwnerOnly:             "chỉ chủ sở hữu mới có thể %s",
+		MsgNoAccess:              "không có quyền truy cập %s này",
+		MsgAlreadySummoning:      "agent đang được triệu hồi",
+		MsgSummoningUnavailable:  "triệu hồi không khả dụng",
+		MsgNoDescription:         "agent không có mô tả để triệu hồi lại",
+		MsgInvalidPath:           "đường dẫn không hợp lệ",
+
+		// Scheduler
+		MsgQueueFull:    "hàng đợi session đã đầy",
+		MsgShuttingDown: "cổng đang tắt, vui lòng thử lại sau",
+
+		// Provider
+		MsgProviderReqFailed: "%s: yêu cầu thất bại: %s",
+
+		// Unknown method
+		MsgUnknownMethod: "phương thức không xác định: %s",
+
+		// Not implemented
+		MsgNotImplemented: "%s chưa được triển khai",
+
+		// Agent links
+		MsgLinksNotConfigured:   "liên kết agent chưa được cấu hình",
+		MsgInvalidDirection:     "hướng phải là outbound, inbound hoặc bidirectional",
+		MsgSourceTargetSame:     "nguồn và đích phải là các agent khác nhau",
+		MsgCannotDelegateOpen:   "không thể ủy quyền cho agent mở — chỉ agent định sẵn mới có thể là đích ủy quyền",
+		MsgNoUpdatesProvided:    "không có cập nhật nào được cung cấp",
+		MsgInvalidLinkStatus:    "trạng thái phải là active hoặc disabled",
+
+		// Teams
+		MsgTeamsNotConfigured:   "nhóm chưa được cấu hình",
+		MsgAgentIsTeamLead:      "agent đã là trưởng nhóm",
+		MsgCannotRemoveTeamLead: "không thể xóa trưởng nhóm",
+
+		// Delegations
+		MsgDelegationsUnavailable: "ủy quyền không khả dụng",
+
+		// Channels
+		MsgCannotDeleteDefaultInst: "không thể xóa phiên bản channel mặc định",
+
+		// Skills
+		MsgSkillsUpdateNotSupported: "skills.update không được hỗ trợ với skill dựa trên tệp",
+		MsgCannotResolveSkillID:     "không thể xác định ID skill dựa trên tệp",
+
+		// Logs
+		MsgInvalidLogAction: "action phải là 'start' hoặc 'stop'",
+
+		// Config
+		MsgRawConfigRequired: "cấu hình raw là bắt buộc",
+		MsgRawPatchRequired:  "patch raw là bắt buộc",
+
+		// Storage / File
+		MsgCannotDeleteSkillsDir: "không thể xóa thư mục skill",
+		MsgFailedToReadFile:      "không thể đọc tệp",
+		MsgFileNotFound:          "không tìm thấy tệp",
+		MsgInvalidVersion:        "phiên bản không hợp lệ",
+		MsgVersionNotFound:       "không tìm thấy phiên bản",
+		MsgFailedToDeleteFile:    "không thể xóa",
+
+		// OAuth
+		MsgNoPendingOAuth:    "không có luồng OAuth đang chờ",
+		MsgFailedToSaveToken: "không thể lưu token",
+
+		// Knowledge Graph
+		MsgEntityIDRequired:       "entity_id là bắt buộc",
+		MsgEntityFieldsRequired:   "external_id, name và entity_type là bắt buộc",
+		MsgTextRequired:           "text là bắt buộc",
+		MsgProviderModelRequired:  "provider và model là bắt buộc",
+		MsgInvalidProviderOrModel: "provider hoặc model không hợp lệ",
+	})
+}
diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go
new file mode 100644
index 00000000..ad018797
--- /dev/null
+++ b/internal/i18n/catalog_zh.go
@@ -0,0 +1,132 @@
+package i18n
+
+func init() {
+	register(LocaleZH, map[string]string{
+		// Common validation
+		MsgRequired:         "%s 是必填项",
+		MsgInvalidID:        "无效的 %s ID",
+		MsgNotFound:         "未找到 %s:%s",
+		MsgAlreadyExists:    "%s 已存在:%s",
+		MsgInvalidRequest:   "无效请求:%s",
+		MsgInvalidJSON:      "无效的 JSON",
+		MsgUnauthorized:     "未授权",
+		MsgPermissionDenied: "权限不足:无法访问 %s",
+		MsgInternalError:    "内部错误:%s",
+		MsgInvalidSlug:      "%s 必须是有效的 slug(小写字母、数字、连字符)",
+		MsgFailedToList:     "获取 %s 列表失败",
+		MsgFailedToCreate:   "创建 %s 失败:%s",
+		MsgFailedToUpdate:   "更新 %s 失败:%s",
+		MsgFailedToDelete:   "删除 %s 失败:%s",
+		MsgFailedToSave:     "保存 %s 失败:%s",
+		MsgInvalidUpdates:   "更新内容无效",
+
+		// Agent
+		MsgAgentNotFound:       "未找到Agent:%s",
+		MsgCannotDeleteDefault: "无法删除默认Agent",
+		MsgUserCtxRequired:     "需要用户上下文",
+
+		// Chat
+		MsgRateLimitExceeded: "请求频率超限 — 请稍候",
+		MsgNoUserMessage:     "未找到用户消息",
+		MsgUserIDRequired:    "user_id 是必填项",
+		MsgMsgRequired:       "消息是必填项",
+
+		// Channel instances
+		MsgInvalidChannelType: "Channel类型无效",
+		MsgInstanceNotFound:   "未找到实例",
+
+		// Cron
+		MsgJobNotFound:     "未找到任务",
+		MsgInvalidCronExpr: "无效的 cron 表达式:%s",
+
+		// Config
+		MsgConfigHashMismatch: "配置已更改(hash 不匹配)",
+
+		// Exec approval
+		MsgExecApprovalDisabled: "执行审批未启用",
+
+		// Pairing
+		MsgSenderChannelRequired: "senderId 和 channel 是必填项",
+		MsgCodeRequired:          "代码是必填项",
+		MsgSenderIDRequired:      "sender_id 是必填项",
+
+		// HTTP API
+		MsgInvalidAuth:           "身份验证无效",
+		MsgMsgsRequired:          "messages 是必填项",
+		MsgUserIDHeader:          "托管模式下需要 X-GoClaw-User-Id 请求头",
+		MsgFileTooLarge:          "文件过大或 multipart 表单无效",
+		MsgMissingFileField:      "缺少 'file' 字段",
+		MsgInvalidFilename:       "文件名无效",
+		MsgChannelKeyReq:         "channel 和 key 是必填项",
+		MsgMethodNotAllowed:      "不允许的请求方法",
+		MsgStreamingNotSupported: "不支持流式传输",
+		MsgOwnerOnly:             "只有所有者才能%s",
+		MsgNoAccess:              "无权访问此%s",
+		MsgAlreadySummoning:      "Agent正在被召唤中",
+		MsgSummoningUnavailable:  "召唤功能不可用",
+		MsgNoDescription:         "Agent没有可供重新召唤的描述",
+		MsgInvalidPath:           "路径无效",
+
+		// Scheduler
+		MsgQueueFull:    "Session队列已满",
+		MsgShuttingDown: "网关正在关闭,请稍后重试",
+
+		// Provider
+		MsgProviderReqFailed: "%s:请求失败:%s",
+
+		// Unknown method
+		MsgUnknownMethod: "未知方法:%s",
+
+		// Not implemented
+		MsgNotImplemented: "%s 尚未实现",
+
+		// Agent links
+		MsgLinksNotConfigured:   "Agent链接未配置",
+		MsgInvalidDirection:     "方向必须是 outbound、inbound 或 bidirectional",
+		MsgSourceTargetSame:     "源和目标必须是不同的Agent",
+		MsgCannotDelegateOpen:   "无法委派给开放型Agent — 只有预定义Agent才能作为委派目标",
+		MsgNoUpdatesProvided:    "未提供更新内容",
+		MsgInvalidLinkStatus:    "状态必须是 active 或 disabled",
+
+		// Teams
+		MsgTeamsNotConfigured:   "团队未配置",
+		MsgAgentIsTeamLead:      "该Agent已是团队负责人",
+		MsgCannotRemoveTeamLead: "无法移除团队负责人",
+
+		// Delegations
+		MsgDelegationsUnavailable: "委派功能不可用",
+
+		// Channels
+		MsgCannotDeleteDefaultInst: "无法删除默认Channel实例",
+
+		// Skills
+		MsgSkillsUpdateNotSupported: "基于文件的Skill不支持 skills.update",
+		MsgCannotResolveSkillID:     "无法解析基于文件的Skill ID",
+
+		// Logs
+		MsgInvalidLogAction: "action 必须是 'start' 或 'stop'",
+
+		// Config
+		MsgRawConfigRequired: "raw 配置是必填项",
+		MsgRawPatchRequired:  "raw 补丁是必填项",
+
+		// Storage / File
+		MsgCannotDeleteSkillsDir: "无法删除Skill目录",
+		MsgFailedToReadFile:      "读取文件失败",
+		MsgFileNotFound:          "文件未找到",
+		MsgInvalidVersion:        "版本无效",
+		MsgVersionNotFound:       "未找到该版本",
+		MsgFailedToDeleteFile:    "删除失败",
+
+		// OAuth
+		MsgNoPendingOAuth:    "没有待处理的 OAuth 流程",
+		MsgFailedToSaveToken: "保存令牌失败",
+
+		// Knowledge Graph
+		MsgEntityIDRequired:       "entity_id 是必填项",
+		MsgEntityFieldsRequired:   "external_id、name 和 entity_type 是必填项",
+		MsgTextRequired:           "text 是必填项",
+		MsgProviderModelRequired:  "provider 和 model 是必填项",
+		MsgInvalidProviderOrModel: "provider 或 model 无效",
+	})
+}
diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go
new file mode 100644
index 00000000..f5c84d69
--- /dev/null
+++ b/internal/i18n/i18n.go
@@ -0,0 +1,80 @@
+// Package i18n provides a lightweight message catalog for localizing
+// user-facing error messages. Messages use fmt.Sprintf-style templates.
+package i18n
+
+import "fmt"
+
+// Supported locales.
+const (
+	LocaleEN = "en"
+	LocaleVI = "vi"
+	LocaleZH = "zh"
+
+	DefaultLocale = LocaleEN
+)
+
+// catalogs maps locale → key → message template.
+var catalogs = map[string]map[string]string{}
+
+// register adds a set of messages for a locale. Called from catalog_*.go init().
+func register(locale string, msgs map[string]string) {
+	if catalogs[locale] == nil {
+		catalogs[locale] = make(map[string]string, len(msgs))
+	}
+	for k, v := range msgs {
+		catalogs[locale][k] = v
+	}
+}
+
+// T returns a localized message for the given key.
+// If the key is not found in the requested locale, it falls back to English.
+// If the key is not found in English either, the key itself is returned.
+// Optional args are applied via fmt.Sprintf if the template contains verbs.
+func T(locale, key string, args ...any) string {
+	msg := lookup(locale, key)
+	if len(args) > 0 {
+		return fmt.Sprintf(msg, args...)
+	}
+	return msg
+}
+
+func lookup(locale, key string) string {
+	if cat, ok := catalogs[locale]; ok {
+		if msg, ok := cat[key]; ok {
+			return msg
+		}
+	}
+	// Fallback to English
+	if locale != LocaleEN {
+		if cat, ok := catalogs[LocaleEN]; ok {
+			if msg, ok := cat[key]; ok {
+				return msg
+			}
+		}
+	}
+	return key
+}
+
+// IsSupported returns true if the locale is a known language.
+func IsSupported(locale string) bool {
+	switch locale {
+	case LocaleEN, LocaleVI, LocaleZH:
+		return true
+	}
+	return false
+}
+
+// Normalize returns a supported locale or the default.
+func Normalize(locale string) string {
+	if IsSupported(locale) {
+		return locale
+	}
+	// Handle common prefixes: "en-US" → "en", "vi-VN" → "vi", "zh-CN" → "zh"
+	if len(locale) >= 2 {
+		prefix := locale[:2]
+		if IsSupported(prefix) {
+			return prefix
+		}
+	}
+	return DefaultLocale
+}
diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go
new file mode 100644
index 00000000..d59c18bf
--- /dev/null
+++ b/internal/i18n/keys.go
@@ -0,0 +1,132 @@
+package i18n
+
+// Message keys for gateway/HTTP error messages.
+// Grouped by domain for easier maintenance.
+const (
+	// --- Common validation ---
+	MsgRequired          = "error.required"           // "%s is required"
+	MsgInvalidID         = "error.invalid_id"         // "invalid %s ID"
+	MsgNotFound          = "error.not_found"          // "%s not found: %s"
+	MsgAlreadyExists     = "error.already_exists"     // "%s already exists: %s"
+	MsgInvalidRequest    = "error.invalid_request"    // "invalid request: %s"
+	MsgInvalidJSON       = "error.invalid_json"       // "invalid JSON"
+	MsgUnauthorized      = "error.unauthorized"       // "unauthorized"
+	MsgPermissionDenied  = "error.permission_denied"  // "permission denied: insufficient role for %s"
+	MsgInternalError     = "error.internal"           // "internal error: %s"
+	MsgInvalidSlug       = "error.invalid_slug"       // "%s must be a valid slug (lowercase letters, numbers, hyphens only)"
+	MsgFailedToList      = "error.failed_to_list"     // "failed to list %s"
+	MsgFailedToCreate    = "error.failed_to_create"   // "failed to create %s: %s"
+	MsgFailedToUpdate    = "error.failed_to_update"   // "failed to update %s: %s"
+	MsgFailedToDelete    = "error.failed_to_delete"   // "failed to delete %s: %s"
+	MsgFailedToSave      = "error.failed_to_save"     // "failed to save %s: %s"
+	MsgInvalidUpdates    = "error.invalid_updates"    // "invalid updates"
+
+	// --- Agent ---
+	MsgAgentNotFound       = "error.agent_not_found"        // "agent not found: %s"
+	MsgCannotDeleteDefault = "error.cannot_delete_default"   // "cannot delete the default agent"
+	MsgUserCtxRequired     = "error.user_ctx_required"       // "user context required"
+
+	// --- Chat ---
+	MsgRateLimitExceeded = "error.rate_limit"          // "rate limit exceeded — please wait"
+	MsgNoUserMessage     = "error.no_user_message"     // "no user message found"
+	MsgUserIDRequired    = "error.user_id_required"    // "user_id is required"
+	MsgMsgRequired       = "error.message_required"    // "message is required"
+
+	// --- Channel instances ---
+	MsgInvalidChannelType = "error.invalid_channel_type" // "invalid channel_type"
+	MsgInstanceNotFound   = "error.instance_not_found"   // "instance not found"
+
+	// --- Cron ---
+	MsgJobNotFound     = "error.job_not_found"     // "job not found"
+	MsgInvalidCronExpr = "error.invalid_cron_expr" // "invalid cron expression: %s"
+
+	// --- Config ---
+	MsgConfigHashMismatch = "error.config_hash_mismatch" // "config has changed (hash mismatch)"
+
+	// --- Exec approval ---
+	MsgExecApprovalDisabled = "error.exec_approval_disabled" // "exec approval is not enabled"
+
+	// --- Pairing ---
+	MsgSenderChannelRequired = "error.sender_channel_required" // "senderId and channel are required"
+	MsgCodeRequired          = "error.code_required"           // "code is required"
+	MsgSenderIDRequired      = "error.sender_id_required"      // "sender_id is required"
+
+	// --- HTTP API ---
+	MsgInvalidAuth          = "error.invalid_auth"              // "invalid authentication"
+	MsgMsgsRequired         = "error.messages_required"         // "messages is required"
+	MsgUserIDHeader         = "error.user_id_header"            // "X-GoClaw-User-Id header is required"
+	MsgFileTooLarge         = "error.file_too_large"            // "file too large or invalid multipart form"
+	MsgMissingFileField     = "error.missing_file_field"        // "missing 'file' field"
+	MsgInvalidFilename      = "error.invalid_filename"          // "invalid filename"
+	MsgChannelKeyReq        = "error.channel_key_required"      // "channel and key are required"
+	MsgMethodNotAllowed     = "error.method_not_allowed"        // "method not allowed"
+	MsgStreamingNotSupported = "error.streaming_not_supported"  // "streaming not supported"
+	MsgOwnerOnly            = "error.owner_only"                // "only owner can %s"
+	MsgNoAccess             = "error.no_access"                 // "no access to this %s"
+	MsgAlreadySummoning     = "error.already_summoning"         // "agent is already being summoned"
+	MsgSummoningUnavailable = "error.summoning_unavailable"     // "summoning not available"
+	MsgNoDescription        = "error.no_description"            // "agent has no description to resummon from"
+	MsgInvalidPath          = "error.invalid_path"              // "invalid path"
+
+	// --- Scheduler ---
+	MsgQueueFull       = "error.queue_full"       // "session queue is full"
+	MsgShuttingDown    = "error.shutting_down"     // "gateway is shutting down, please retry shortly"
+
+	// --- Provider ---
+	MsgProviderReqFailed = "error.provider_request_failed" // "%s: request failed: %s"
+
+	// --- Unknown method ---
+	MsgUnknownMethod = "error.unknown_method" // "unknown method: %s"
+
+	// --- Not implemented ---
+	MsgNotImplemented = "error.not_implemented" // "%s not yet implemented"
+
+	// --- Agent links ---
+	MsgLinksNotConfigured   = "error.links_not_configured"    // "agent links not configured"
+	MsgInvalidDirection     = "error.invalid_direction"       // "direction must be outbound, inbound, or bidirectional"
+	MsgSourceTargetSame     = "error.source_target_same"      // "source and target must be different agents"
+	MsgCannotDelegateOpen   = "error.cannot_delegate_open"    // "cannot delegate to open agents — only predefined agents can be delegation targets"
+	MsgNoUpdatesProvided    = "error.no_updates_provided"     // "no updates provided"
+	MsgInvalidLinkStatus    = "error.invalid_link_status"     // "status must be active or disabled"
+
+	// --- Teams ---
+	MsgTeamsNotConfigured   = "error.teams_not_configured"    // "teams not configured"
+	MsgAgentIsTeamLead      = "error.agent_is_team_lead"      // "agent is already the team lead"
+	MsgCannotRemoveTeamLead = "error.cannot_remove_team_lead" // "cannot remove the team lead"
+
+	// --- Delegations ---
+	MsgDelegationsUnavailable = "error.delegations_not_available" // "delegations not available"
+
+	// --- Channels ---
+	MsgCannotDeleteDefaultInst  = "error.cannot_delete_default_inst"  // "cannot delete default channel instance"
+
+	// --- Skills ---
+	MsgSkillsUpdateNotSupported = "error.skills_update_not_supported" // "skills.update not supported for file-based skills"
+	MsgCannotResolveSkillID     = "error.cannot_resolve_skill_id"     // "cannot resolve skill ID for file-based skill"
+
+	// --- Logs ---
+	MsgInvalidLogAction = "error.invalid_log_action" // "action must be 'start' or 'stop'"
+
+	// --- Config ---
+	MsgRawConfigRequired = "error.raw_config_required" // "raw config is required"
+	MsgRawPatchRequired  = "error.raw_patch_required"  // "raw patch is required"
+
+	// --- Storage / File ---
+	MsgCannotDeleteSkillsDir = "error.cannot_delete_skills_dir" // "cannot delete skills directories"
+	MsgFailedToReadFile      = "error.failed_to_read_file"      // "failed to read file"
+	MsgFileNotFound          = "error.file_not_found"           // "file not found"
+	MsgInvalidVersion        = "error.invalid_version"          // "invalid version"
+	MsgVersionNotFound       = "error.version_not_found"        // "version not found"
+	MsgFailedToDeleteFile    = "error.failed_to_delete_file"    // "failed to delete"
+
+	// --- OAuth ---
+	MsgNoPendingOAuth    = "error.no_pending_oauth"    // "no pending OAuth flow"
+	MsgFailedToSaveToken = "error.failed_to_save_token" // "failed to save token"
+
+	// --- Knowledge Graph ---
+	MsgEntityIDRequired           = "error.entity_id_required"            // "entity_id is required"
+	MsgEntityFieldsRequired       = "error.entity_fields_required"        // "external_id, name, and entity_type are required"
+	MsgTextRequired               = "error.text_required"                 // "text is required"
+	MsgProviderModelRequired      = "error.provider_model_required"       // "provider and model are required"
+	MsgInvalidProviderOrModel     = "error.invalid_provider_or_model"     // "invalid provider or model"
+)
diff --git a/internal/store/context.go b/internal/store/context.go
index c27c2a86..4edfad5c 100644
--- a/internal/store/context.go
+++ b/internal/store/context.go
@@ -21,6 +21,8 @@ const (
 	SenderIDKey contextKey = "goclaw_sender_id"
 	// SelfEvolveKey indicates whether a predefined agent can update its SOUL.md.
 	SelfEvolveKey contextKey = "goclaw_self_evolve"
+	// LocaleKey is the context key for the user's preferred locale (e.g. "en", "vi", "zh").
+	LocaleKey contextKey = "goclaw_locale"
 )
 
 // WithUserID returns a new context with the given user ID.
@@ -87,3 +89,16 @@ func SelfEvolveFromContext(ctx context.Context) bool {
 	}
 	return false
 }
+
+// WithLocale returns a new context with the given locale.
+func WithLocale(ctx context.Context, locale string) context.Context {
+	return context.WithValue(ctx, LocaleKey, locale)
+}
+
+// LocaleFromContext extracts the locale from context. Returns "en" if not set.
+func LocaleFromContext(ctx context.Context) string {
+	if v, ok := ctx.Value(LocaleKey).(string); ok && v != "" {
+		return v
+	}
+	return "en"
+}
diff --git a/ui/web/package.json b/ui/web/package.json
index 4d7dd850..bdf99949 100644
--- a/ui/web/package.json
+++ b/ui/web/package.json
@@ -22,10 +22,12 @@
     "d3-force": "^3.0.0",
     "framer-motion": "^12.34.3",
     "highlight.js": "^11.11.1",
+    "i18next": "^25.8.15",
     "lucide-react": "^0.468.0",
     "radix-ui": "^1.4.3",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
+    "react-i18next": "^16.5.6",
     "react-markdown": "^10.1.0",
     "react-router": "^7.1.0",
     "rehype-highlight": "^7.0.2",
diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml
index 25412849..17212fe9 100644
--- a/ui/web/pnpm-lock.yaml
+++ b/ui/web/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
       highlight.js:
         specifier: ^11.11.1
         version: 11.11.1
+      i18next:
+        specifier: ^25.8.15
+        version: 25.8.15(typescript@5.7.3)
       lucide-react:
         specifier: ^0.468.0
         version: 0.468.0(react@19.2.4)
@@ -56,6 +59,9 @@ importers:
       react-dom:
         specifier: ^19.0.0
         version: 19.2.4(react@19.2.4)
+      react-i18next:
+        specifier: ^16.5.6
+        version: 16.5.6(i18next@25.8.15(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3)
       react-markdown:
         specifier: ^10.1.0
         version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
@@ -179,6 +185,10 @@ packages:
     peerDependencies:
       '@babel/core': ^7.0.0-0
 
+  '@babel/runtime@7.28.6':
+    resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
+    engines: {node: '>=6.9.0'}
+
   '@babel/template@7.28.6':
     resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
     engines: {node: '>=6.9.0'}
@@ -1654,9 +1664,20 @@ packages:
     resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
     engines: {node: '>=12.0.0'}
 
+  html-parse-stringify@3.0.1:
+    resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
   html-url-attributes@3.0.1:
     resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
 
+  i18next@25.8.15:
+    resolution: {integrity: sha512-DW+vfwYtt+8lqpknhPGBfPL2P4SvtUv+KGDZAjfPrbnKAJkzVWGNRYw/rNkzxSzCBvnpiUnF5AgX7kL8hCg6uw==}
+    peerDependencies:
+      typescript: ^5
+    peerDependenciesMeta:
+      typescript:
+        optional: true
+
   inline-style-parser@0.2.7:
     resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
 
@@ -1972,6 +1993,22 @@ packages:
     peerDependencies:
       react: ^19.2.4
 
+  react-i18next@16.5.6:
+    resolution: {integrity: sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==}
+    peerDependencies:
+      i18next: '>= 25.6.2'
+      react: '>= 16.8.0'
+      react-dom: '*'
+      react-native: '*'
+      typescript: ^5
+    peerDependenciesMeta:
+      react-dom:
+        optional: true
+      react-native:
+        optional: true
+      typescript:
+        optional: true
+
   react-markdown@10.1.0:
     resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
     peerDependencies:
@@ -2204,6 +2241,10 @@ packages:
       yaml:
         optional: true
 
+  void-elements@3.1.0:
+    resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+    engines: {node: '>=0.10.0'}
+
   ws@8.19.0:
     resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
     engines: {node: '>=10.0.0'}
@@ -2346,6 +2387,8 @@ snapshots:
       '@babel/core': 7.29.0
       '@babel/helper-plugin-utils': 7.28.6
 
+  '@babel/runtime@7.28.6': {}
+
   '@babel/template@7.28.6':
     dependencies:
       '@babel/code-frame': 7.29.0
@@ -3752,8 +3795,18 @@ snapshots:
 
   highlight.js@11.11.1: {}
 
+  html-parse-stringify@3.0.1:
+    dependencies:
+      void-elements: 3.1.0
+
   html-url-attributes@3.0.1: {}
 
+  i18next@25.8.15(typescript@5.7.3):
+    dependencies:
+      '@babel/runtime': 7.28.6
+    optionalDependencies:
+      typescript: 5.7.3
+
   inline-style-parser@0.2.7: {}
 
   is-alphabetical@2.0.1: {}
@@ -4299,6 +4352,17 @@ snapshots:
       react: 19.2.4
       scheduler: 0.27.0
 
+  react-i18next@16.5.6(i18next@25.8.15(typescript@5.7.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.7.3):
+    dependencies:
+      '@babel/runtime': 7.28.6
+      html-parse-stringify: 3.0.1
+      i18next: 25.8.15(typescript@5.7.3)
+      react: 19.2.4
+      use-sync-external-store: 1.6.0(react@19.2.4)
+    optionalDependencies:
+      react-dom: 19.2.4(react@19.2.4)
+      typescript: 5.7.3
+
   react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
     dependencies:
       '@types/hast': 3.0.4
@@ -4562,6 +4626,8 @@ snapshots:
       jiti: 2.6.1
       lightningcss: 1.31.1
 
+  void-elements@3.1.0: {}
+
   ws@8.19.0: {}
 
   yallist@3.1.1: {}
diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts
index fe2054ed..07a5832b 100644
--- a/ui/web/src/api/ws-client.ts
+++ b/ui/web/src/api/ws-client.ts
@@ -197,6 +197,7 @@ export class WsClient {
         token: this.getToken(),
         user_id: this.getUserId(),
         sender_id: this.getSenderID(),
+        locale: localStorage.getItem("goclaw:language") || "en",
         protocolVersion: PROTOCOL_VERSION,
       });
       if (this.connectGeneration !== generation) return;
diff --git a/ui/web/src/components/chat/agent-selector.tsx b/ui/web/src/components/chat/agent-selector.tsx
index b40f85e5..f2d33db4 100644
--- a/ui/web/src/components/chat/agent-selector.tsx
+++ b/ui/web/src/components/chat/agent-selector.tsx
@@ -1,4 +1,5 @@
 import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
 import { Bot, ChevronDown } from "lucide-react";
 import { useHttp } from "@/hooks/use-ws";
 import { useAuthStore } from "@/stores/use-auth-store";
@@ -10,6 +11,7 @@ interface AgentSelectorProps {
 }
 
 export function AgentSelector({ value, onChange }: AgentSelectorProps) {
+  const { t } = useTranslation("common");
   const http = useHttp();
   const connected = useAuthStore((s) => s.connected);
   const [agents, setAgents] = useState([]);
@@ -37,7 +39,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
       >
         
         
-          {selected?.display_name ?? selected?.agent_key ?? (value || "Select agent")}
+          {selected?.display_name ?? selected?.agent_key ?? (value || t("selectAgent"))}
         
         
       
@@ -48,7 +50,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) {
           
{agents.length === 0 && (
- No agents available + {t("noAgentsAvailable")}
)} {agents.map((agent) => ( @@ -68,7 +70,7 @@ export function AgentSelector({ value, onChange }: AgentSelectorProps) { {agent.display_name || agent.agent_key} {agent.is_default && ( - default + {t("default")} )} ))} diff --git a/ui/web/src/components/chat/chat-input.tsx b/ui/web/src/components/chat/chat-input.tsx index ede4bb27..abea3e98 100644 --- a/ui/web/src/components/chat/chat-input.tsx +++ b/ui/web/src/components/chat/chat-input.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useCallback, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; import { Send, Square } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,7 @@ interface ChatInputProps { } export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputProps) { + const { t } = useTranslation("common"); const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -49,7 +51,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} onInput={handleInput} - placeholder="Send a message... (Shift+Enter for new line)" + placeholder={t("sendMessage")} disabled={disabled} rows={1} className="flex-1 resize-none rounded-lg border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" @@ -59,7 +61,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro variant="destructive" size="icon-lg" onClick={onAbort} - title="Stop generation" + title={t("stopGeneration")} > @@ -68,7 +70,7 @@ export function ChatInput({ onSend, onAbort, isRunning, disabled }: ChatInputPro size="icon-lg" onClick={handleSend} disabled={!value.trim() || disabled} - title="Send message" + title={t("sendMessageTitle")} > diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index 30d275d3..daa823c4 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Image, Video, @@ -126,33 +127,26 @@ const mediaIcons: Record = { animation: Video, }; -const mediaLabels: Record = { - image: "Image", - video: "Video", - audio: "Audio", - voice: "Voice message", - document: "Document", - animation: "Animation", -}; - function MediaBadge({ mediaType }: { mediaType: string }) { + const { t } = useTranslation("chat"); const Icon = mediaIcons[mediaType] ?? FileText; - const label = mediaLabels[mediaType] ?? mediaType; + const label = t(`media.${mediaType}`, { defaultValue: mediaType }); return ( - {label} attached + {label} {t("media.attached")} ); } function ForwardBadge({ from, date }: { from: string; date: string }) { + const { t } = useTranslation("chat"); return (
- Forwarded from {from} + {t("forwardedFrom")} {from} {date && · {date}}
@@ -160,11 +154,12 @@ function ForwardBadge({ from, date }: { from: string; date: string }) { } function ReplyQuote({ sender, body }: { sender: string; body: string }) { + const { t } = useTranslation("chat"); return (
- Replying to {sender} + {t("replyingTo")} {sender}
{body}
diff --git a/ui/web/src/components/chat/session-switcher.tsx b/ui/web/src/components/chat/session-switcher.tsx index 09a59f6a..3dfe5f24 100644 --- a/ui/web/src/components/chat/session-switcher.tsx +++ b/ui/web/src/components/chat/session-switcher.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MessageSquare } from "lucide-react"; import { formatRelativeTime } from "@/lib/format"; import { parseSessionKey } from "@/lib/session-key"; @@ -16,6 +17,7 @@ export function SessionSwitcher({ onSelect, loading, }: SessionSwitcherProps) { + const { t } = useTranslation("common"); if (sessions.length === 0 && loading) { return (
@@ -29,7 +31,7 @@ export function SessionSwitcher({ if (sessions.length === 0) { return (
- No sessions yet. Start a chat! + {t("noSessions")}
); } @@ -56,7 +58,7 @@ export function SessionSwitcher({
{label}
- {session.messageCount} messages + {session.messageCount} {t("messages")} {formatRelativeTime(session.updated)}
diff --git a/ui/web/src/components/chat/thinking-block.tsx b/ui/web/src/components/chat/thinking-block.tsx index 11b1749e..59f440ed 100644 --- a/ui/web/src/components/chat/thinking-block.tsx +++ b/ui/web/src/components/chat/thinking-block.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, Brain } from "lucide-react"; interface ThinkingBlockProps { @@ -7,6 +8,7 @@ interface ThinkingBlockProps { } export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps) { + const { t } = useTranslation("common"); const [expanded, setExpanded] = useState(isStreaming); return ( @@ -18,7 +20,7 @@ export function ThinkingBlock({ text, isStreaming = false }: ThinkingBlockProps) > - Thinking{isStreaming ? "..." : ""} + {isStreaming ? t("thinkingStreaming") : t("thinking")} {expanded ? ( diff --git a/ui/web/src/components/chat/tool-call-card.tsx b/ui/web/src/components/chat/tool-call-card.tsx index d6b847db..5ab85ffb 100644 --- a/ui/web/src/components/chat/tool-call-card.tsx +++ b/ui/web/src/components/chat/tool-call-card.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Wrench, Check, AlertTriangle, Loader2, ChevronDown, ChevronRight, Zap } from "lucide-react"; import type { ToolStreamEntry } from "@/types/chat"; @@ -18,6 +19,7 @@ interface ToolCallCardProps { } export function ToolCallCard({ entry }: ToolCallCardProps) { + const { t } = useTranslation("common"); const hasDetails = entry.arguments || entry.result; const hasError = entry.phase === "error" && !!entry.errorContent; const canExpand = hasDetails || hasError; @@ -57,7 +59,7 @@ export function ToolCallCard({ entry }: ToolCallCardProps) { )} {entry.arguments && Object.keys(entry.arguments).length > 0 && (
-
Arguments
+
{t("toolArguments")}
                 {JSON.stringify(entry.arguments, null, 2)}
               
@@ -65,7 +67,7 @@ export function ToolCallCard({ entry }: ToolCallCardProps) { )} {entry.result && (
-
Result
+
{t("toolResult")}
                 {entry.result}
               
@@ -103,9 +105,10 @@ function ToolIcon({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill } function PhaseLabel({ phase, isSkill }: { phase: ToolStreamEntry["phase"]; isSkill?: boolean }) { + const { t } = useTranslation("common"); const labels: Record = isSkill - ? { calling: "Activating...", completed: "Activated", error: "Failed" } - : { calling: "Running...", completed: "Done", error: "Failed" }; + ? { calling: t("skillActivating"), completed: t("skillActivated"), error: t("toolFailed") } + : { calling: t("toolRunning"), completed: t("toolDone"), error: t("toolFailed") }; const colors: Record = { calling: "text-blue-500", completed: "text-green-500", diff --git a/ui/web/src/components/layout/app-layout.tsx b/ui/web/src/components/layout/app-layout.tsx index 163b070e..1e758370 100644 --- a/ui/web/src/components/layout/app-layout.tsx +++ b/ui/web/src/components/layout/app-layout.tsx @@ -1,5 +1,6 @@ import { Outlet } from "react-router"; import { WifiOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Sidebar } from "./sidebar"; import { Topbar } from "./topbar"; import { useUiStore } from "@/stores/use-ui-store"; @@ -8,6 +9,7 @@ import { useIsMobile } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; export function AppLayout() { + const { t } = useTranslation("common"); const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed); const mobileSidebarOpen = useUiStore((s) => s.mobileSidebarOpen); const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen); @@ -43,7 +45,7 @@ export function AppLayout() { {!connected && (
- Disconnected from gateway. Attempting to reconnect... + {t("disconnectedGateway")}
)}
diff --git a/ui/web/src/components/layout/connection-status.tsx b/ui/web/src/components/layout/connection-status.tsx index 123ef00f..d5447014 100644 --- a/ui/web/src/components/layout/connection-status.tsx +++ b/ui/web/src/components/layout/connection-status.tsx @@ -1,7 +1,9 @@ +import { useTranslation } from "react-i18next"; import { useAuthStore } from "@/stores/use-auth-store"; import { cn } from "@/lib/utils"; export function ConnectionStatus() { + const { t } = useTranslation("common"); const connected = useAuthStore((s) => s.connected); return ( @@ -12,7 +14,7 @@ export function ConnectionStatus() { connected ? "bg-green-500" : "bg-red-500", )} /> - {connected ? "Connected" : "Disconnected"} + {connected ? t("connected") : t("disconnected")}
); } diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index e114784d..4896797e 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -24,6 +24,7 @@ import { Inbox, Brain, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; import { SidebarItem } from "./sidebar-item"; import { ConnectionStatus } from "./connection-status"; @@ -37,6 +38,7 @@ interface SidebarProps { } export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + const { t } = useTranslation("sidebar"); const { pendingCount } = usePendingPairingsCount(); return ( @@ -66,40 +68,40 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { {/* Nav items */} diff --git a/ui/web/src/components/layout/topbar.tsx b/ui/web/src/components/layout/topbar.tsx index f5ec3c2a..d2117bd8 100644 --- a/ui/web/src/components/layout/topbar.tsx +++ b/ui/web/src/components/layout/topbar.tsx @@ -1,14 +1,18 @@ -import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell } from "lucide-react"; +import { Moon, Sun, PanelLeftClose, PanelLeftOpen, Menu, LogOut, Bell, Globe } from "lucide-react"; import { useNavigate } from "react-router"; +import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; import { useAuthStore } from "@/stores/use-auth-store"; import { useIsMobile } from "@/hooks/use-media-query"; import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count"; -import { ROUTES } from "@/lib/constants"; +import { ROUTES, SUPPORTED_LANGUAGES, LANGUAGE_LABELS, type Language } from "@/lib/constants"; export function Topbar() { + const { t } = useTranslation("topbar"); const theme = useUiStore((s) => s.theme); const setTheme = useUiStore((s) => s.setTheme); + const language = useUiStore((s) => s.language); + const setLanguage = useUiStore((s) => s.setLanguage); const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed); const toggleSidebar = useUiStore((s) => s.toggleSidebar); const setMobileSidebarOpen = useUiStore((s) => s.setMobileSidebarOpen); @@ -24,13 +28,19 @@ export function Topbar() { ? () => setMobileSidebarOpen(true) : toggleSidebar; + const cycleLanguage = () => { + const idx = SUPPORTED_LANGUAGES.indexOf(language); + const next = SUPPORTED_LANGUAGES[(idx + 1) % SUPPORTED_LANGUAGES.length] as Language; + setLanguage(next); + }; + return (
+ + @@ -69,7 +88,7 @@ export function Topbar() { diff --git a/ui/web/src/components/shared/confirm-delete-dialog.tsx b/ui/web/src/components/shared/confirm-delete-dialog.tsx index 0d1a3346..8d998542 100644 --- a/ui/web/src/components/shared/confirm-delete-dialog.tsx +++ b/ui/web/src/components/shared/confirm-delete-dialog.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -27,10 +28,11 @@ export function ConfirmDeleteDialog({ title, description, confirmValue, - confirmLabel = "Delete", + confirmLabel, onConfirm, loading, }: ConfirmDeleteDialogProps) { + const { t } = useTranslation("common"); const [inputValue, setInputValue] = useState(""); useEffect(() => { @@ -48,7 +50,7 @@ export function ConfirmDeleteDialog({

- Type {confirmValue} to confirm. + {t("typeToConfirmPrefix")} {confirmValue} {t("typeToConfirmSuffix")}

diff --git a/ui/web/src/components/shared/confirm-dialog.tsx b/ui/web/src/components/shared/confirm-dialog.tsx index 5904d31c..be593184 100644 --- a/ui/web/src/components/shared/confirm-dialog.tsx +++ b/ui/web/src/components/shared/confirm-dialog.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -24,11 +25,12 @@ export function ConfirmDialog({ onOpenChange, title, description, - confirmLabel = "Confirm", + confirmLabel, variant = "default", onConfirm, loading, }: ConfirmDialogProps) { + const { t } = useTranslation("common"); return ( @@ -38,10 +40,10 @@ export function ConfirmDialog({ diff --git a/ui/web/src/components/shared/file-browser.tsx b/ui/web/src/components/shared/file-browser.tsx index a0f990c6..cf3d2b62 100644 --- a/ui/web/src/components/shared/file-browser.tsx +++ b/ui/web/src/components/shared/file-browser.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { formatSize, type TreeNode } from "@/lib/file-helpers"; import { FileTreePanel } from "@/components/shared/file-tree"; import { FileContentPanel } from "@/components/shared/file-viewers"; @@ -33,6 +34,7 @@ export function FileBrowser({ showSize?: boolean; }) { const isMobile = useIsMobile(); + const { t } = useTranslation("common"); const containerRef = useRef(null); const [treeWidth, setTreeWidth] = useState(220); const [mobileShowTree, setMobileShowTree] = useState(true); @@ -82,7 +84,7 @@ export function FileBrowser({ onClick={() => setMobileShowTree(true)} className="text-primary hover:underline cursor-pointer shrink-0" > - ← Files + ← {t("filesBack")} {fileContent && ( <> diff --git a/ui/web/src/components/shared/file-tree.tsx b/ui/web/src/components/shared/file-tree.tsx index 65e7d442..8c1607a7 100644 --- a/ui/web/src/components/shared/file-tree.tsx +++ b/ui/web/src/components/shared/file-tree.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Folder, FolderOpen, @@ -119,6 +120,7 @@ export function FileTreePanel({ onDelete?: (path: string, isDir: boolean) => void; showSize?: boolean; }) { + const { t } = useTranslation("common"); if (filesLoading) { return (
@@ -127,7 +129,7 @@ export function FileTreePanel({ ); } if (tree.length === 0) { - return

No files found.

; + return

{t("noFiles")}

; } return ( <> diff --git a/ui/web/src/components/shared/file-viewers.tsx b/ui/web/src/components/shared/file-viewers.tsx index daa121be..e838898c 100644 --- a/ui/web/src/components/shared/file-viewers.tsx +++ b/ui/web/src/components/shared/file-viewers.tsx @@ -1,4 +1,5 @@ import { useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { Check, Copy, Loader2 } from "lucide-react"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; import { useClipboard } from "@/hooks/use-clipboard"; @@ -162,6 +163,7 @@ export function FileContentPanel({ fileContent: { content: string; path: string; size: number } | null; contentLoading: boolean; }) { + const { t } = useTranslation("common"); if (contentLoading) { return (
@@ -174,7 +176,7 @@ export function FileContentPanel({ } return (
- Select a file to view its content + {t("selectFileToView")}
); } diff --git a/ui/web/src/components/shared/pagination.tsx b/ui/web/src/components/shared/pagination.tsx index 6337bfd4..7311079a 100644 --- a/ui/web/src/components/shared/pagination.tsx +++ b/ui/web/src/components/shared/pagination.tsx @@ -1,4 +1,5 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Select, @@ -29,16 +30,17 @@ export function Pagination({ onPageSizeChange, pageSizes = PAGE_SIZES, }: PaginationProps) { + const { t } = useTranslation("common"); if (total === 0) return null; return (
- {total} {total === 1 ? "item" : "items"} + {total} {t("items")}
- Rows + {t("rows")}
- Page {page} of {totalPages} + {t("pageOf", { page, totalPages })}
)}
{shouldShowVerify && verifyResult && (

- {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"} + {verifyResult.valid ? t("modelVerified") : verifyResult.error || t("verificationFailed")}

)}
diff --git a/ui/web/src/components/shared/search-input.tsx b/ui/web/src/components/shared/search-input.tsx index fb4b7e33..c35cd781 100644 --- a/ui/web/src/components/shared/search-input.tsx +++ b/ui/web/src/components/shared/search-input.tsx @@ -1,4 +1,5 @@ import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { useDebounce } from "@/hooks/use-debounce"; import { useState, useEffect } from "react"; @@ -14,10 +15,11 @@ interface SearchInputProps { export function SearchInput({ value, onChange, - placeholder = "Search...", + placeholder, className, delay = 300, }: SearchInputProps) { + const { t } = useTranslation("common"); const [local, setLocal] = useState(value); const debounced = useDebounce(local, delay); @@ -35,7 +37,7 @@ export function SearchInput({ setLocal(e.target.value)} - placeholder={placeholder} + placeholder={placeholder ?? t("searchPlaceholder")} className="pl-9" />
diff --git a/ui/web/src/components/shared/tag-input.tsx b/ui/web/src/components/shared/tag-input.tsx index 1e20ba0f..4c00d1c0 100644 --- a/ui/web/src/components/shared/tag-input.tsx +++ b/ui/web/src/components/shared/tag-input.tsx @@ -1,4 +1,5 @@ import { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -12,9 +13,10 @@ interface TagInputProps { export function TagInput({ value, onChange, - placeholder = "Type and press Enter...", + placeholder, className, }: TagInputProps) { + const { t } = useTranslation("common"); const [input, setInput] = useState(""); const inputRef = useRef(null); @@ -70,7 +72,7 @@ export function TagInput({ onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => { if (input.trim()) addTag(input); }} - placeholder={value.length === 0 ? placeholder : ""} + placeholder={value.length === 0 ? (placeholder ?? t("typeAndPressEnter")) : ""} className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none" />
diff --git a/ui/web/src/components/shared/tool-name-select.tsx b/ui/web/src/components/shared/tool-name-select.tsx index 68168484..ca558b16 100644 --- a/ui/web/src/components/shared/tool-name-select.tsx +++ b/ui/web/src/components/shared/tool-name-select.tsx @@ -1,4 +1,5 @@ import { useMemo, useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { X, ChevronDownIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useBuiltinTools } from "@/pages/builtin-tools/hooks/use-builtin-tools"; @@ -20,9 +21,10 @@ interface ToolOption { export function ToolNameSelect({ value, onChange, - placeholder = "Select or type tool names...", + placeholder, className, }: ToolNameSelectProps) { + const { t } = useTranslation("common"); const { tools: builtinTools } = useBuiltinTools(); const { tools: customTools } = useCustomTools(); const [open, setOpen] = useState(false); @@ -127,7 +129,7 @@ export function ToolNameSelect({ }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} - placeholder={value.length === 0 ? placeholder : ""} + placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeTools")) : ""} className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none" /> 0 && ( <>
- Built-in + {t("builtinTools")}
{grouped.builtin.map((t) => (
{agent.status === "summoning" ? ( - Summoning... + {t("card.summoning")} ) : agent.status === "summon_failed" ? ( - Failed + {t("card.summonFailed")} ) : ( @@ -80,8 +82,8 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro {agent.agent_type === "predefined" - ? "Shared context files (SOUL.md, IDENTITY.md, AGENTS.md) with per-user USER.md instances." - : "Each user gets their own full set of context files."} + ? t("card.predefinedTooltip") + : t("card.openTooltip")} {agent.agent_type === "predefined" && ( @@ -92,13 +94,13 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro className={`text-[11px] ${selfEvolve ? "bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300" : "text-muted-foreground"}`} > - {selfEvolve ? "Evolving" : "Static"} + {selfEvolve ? t("card.evolving") : t("card.static")} {selfEvolve - ? "Agent can self-evolve its communication style (SOUL.md). Identity and workflow stay fixed." - : "Agent style is static. Enable Self-Evolution in settings to allow style adaptation."} + ? t("card.evolvingTooltip") + : t("card.staticTooltip")} )} @@ -118,7 +120,7 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro }} > - Resummon + {t("card.resummon")} )} {onDelete && ( @@ -132,7 +134,7 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro }} > - Delete + {t("card.delete")} )}
diff --git a/ui/web/src/pages/agents/agent-create-dialog.tsx b/ui/web/src/pages/agents/agent-create-dialog.tsx index 42960a4a..3a708877 100644 --- a/ui/web/src/pages/agents/agent-create-dialog.tsx +++ b/ui/web/src/pages/agents/agent-create-dialog.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, @@ -33,6 +34,7 @@ interface AgentCreateDialogProps { } export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateDialogProps) { + const { t } = useTranslation("agents"); const { providers } = useProviders(); const [agentKey, setAgentKey] = useState(""); const [keyTouched, setKeyTouched] = useState(false); @@ -99,7 +101,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD setSelfEvolve(false); setError(""); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create agent"); + setError(err instanceof Error ? err.message : t("create.failedToCreate")); } finally { setLoading(false); } @@ -114,12 +116,12 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD - Create Agent + {t("create.title")}
- +
- + setAgentKey(slugify(agentKey))} - placeholder="e.g. my-agent" + placeholder={t("create.agentKeyPlaceholder")} /> -

Lowercase, numbers, hyphens

+

{t("create.agentKeyHint")}

- + {enabledProviders.length > 0 ? ( setNewUserId(e.target.value)} - placeholder="Enter user ID..." + placeholder={t("shares.userIdPlaceholder")} onKeyDown={(e) => { if (e.key === "Enter" && newUserId.trim()) handleAddShare(); }} />
- +
- Max History Share (0-1) + {t(`${s}.maxHistoryShare`)}
- Min Messages + {t(`${s}.minMessages`)}
- Keep Last Messages + {t(`${s}.keepLastMessages`)} - Memory Flush + {t(`${s}.memoryFlush`)}
{value.memoryFlush?.enabled && (
- Soft Threshold (tokens) + {t(`${s}.softThreshold`)}
@@ -30,7 +32,7 @@ export function ConfigSection({
{children}
) : (

- Using global defaults from config.json + {t("config.usingGlobalDefaults")}

)} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx index 50332efb..4cfb0cf8 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/context-pruning-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { @@ -18,16 +19,18 @@ interface ContextPruningSectionProps { } export function ContextPruningSection({ enabled, value, onToggle, onChange }: ContextPruningSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.contextPruning"; return (
- Mode + {t(`${s}.mode`)}
- Keep Last Assistants + {t(`${s}.keepLastAssistants`)}
- Soft Trim Ratio (0-1) + {t(`${s}.softTrimRatio`)}
- Hard Clear Ratio (0-1) + {t(`${s}.hardClearRatio`)}
- Min Prunable Tool Chars + {t(`${s}.minPrunableToolChars`)} -

Soft Trim

+

{t(`${s}.softTrim`)}

- Max Chars + {t(`${s}.maxChars`)}
- Head Chars + {t(`${s}.headChars`)}
- Tail Chars + {t(`${s}.tailChars`)} -

Hard Clear

+

{t(`${s}.hardClear`)}

- Enabled + {t(`${s}.enabled`)}
- Placeholder Text + {t(`${s}.placeholderText`)} @@ -24,15 +27,15 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect checked={value.enabled ?? true} onCheckedChange={(v) => onChange({ ...value, enabled: v })} /> - Enabled + {t("configSections.contextPruning.enabled")}
onChange({ ...value, embedding_provider: v || undefined })} model={value.embedding_model ?? ""} onModelChange={(v) => onChange({ ...value, embedding_model: v || undefined })} - providerLabel="Embedding Provider" - modelLabel="Embedding Model" + providerLabel={t(`${s}.embeddingProvider`)} + modelLabel={t(`${s}.embeddingModel`)} providerTip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider." modelTip="Embedding model name (e.g. text-embedding-3-small). Must be supported by the provider." providerPlaceholder="(auto)" @@ -40,7 +43,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect />
- Max Results + {t(`${s}.maxResults`)}
- Max Chunk Length + {t(`${s}.maxChunkLen`)}
- Vector Weight + {t(`${s}.vectorWeight`)}
- Text Weight + {t(`${s}.textWeight`)} { @@ -33,8 +36,8 @@ export function OtherConfigSection({ enabled, value, onToggle, onChange }: Other return ( @@ -47,9 +50,9 @@ export function OtherConfigSection({ enabled, value, onToggle, onChange }: Other />
- {!validJson && Invalid JSON syntax} + {!validJson && {t(`${s}.invalidJson`)}}
); diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx index 5cc8cb6a..69dd9f17 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/quality-gates-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -28,6 +29,9 @@ const defaultGate: QualityGateConfig = { }; export function QualityGatesSection({ enabled, value, onToggle, onChange }: QualityGatesSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.qualityGates"; + const updateGate = (i: number, patch: Partial) => { const next = [...value]; next[i] = { ...next[i], ...patch } as QualityGateConfig; @@ -44,20 +48,20 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual return ( {value.length === 0 ? (

- No quality gates configured. Click “Add Gate” to create one. + {t(`${s}.noGates`)}

) : ( value.map((gate, i) => (
- Gate #{i + 1} + {t(`${s}.gateNumber`, { number: i + 1 })} ); diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx index 272603ae..28260d45 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/sandbox-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { @@ -18,16 +19,18 @@ interface SandboxSectionProps { } export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.sandbox"; return (
- Mode + {t(`${s}.mode`)}
- Workspace Access + {t(`${s}.workspaceAccess`)}
- Scope + {t(`${s}.scope`)}
- Timeout (sec) + {t(`${s}.timeout`)}
- Memory (MB) + {t(`${s}.memoryMb`)}
- CPUs + {t(`${s}.cpus`)} onChange({ ...value, network_enabled: v })} /> - Network Enabled + {t(`${s}.networkEnabled`)}
); diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx index 72d22ef9..b5f78d99 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/subagents-section.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Combobox } from "@/components/ui/combobox"; import { @@ -21,25 +22,23 @@ interface SubagentsSectionProps { } export function SubagentsSection({ enabled, value, onToggle, onChange }: SubagentsSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.subagents"; const { providers } = useProviders(); const enabledProviders = providers.filter((p) => p.enabled); - // Use the first enabled provider for model list suggestions - const defaultProvider = useMemo( - () => enabledProviders[0], - [enabledProviders], - ); + const defaultProvider = useMemo(() => enabledProviders[0], [enabledProviders]); const { models, loading: modelsLoading } = useProviderModels(defaultProvider?.id, defaultProvider?.provider_type); return (
- Max Concurrent + {t(`${s}.maxConcurrent`)}
- Max Spawn Depth + {t(`${s}.maxSpawnDepth`)}
- Archive After (minutes) + {t(`${s}.archiveAfter`)}
- Model Override + {t(`${s}.modelOverride`)} onChange({ ...value, model: v || undefined })} options={models.map((m) => ({ value: m.id, label: m.name }))} - placeholder={modelsLoading ? "Loading models..." : "(inherit from agent)"} + placeholder={modelsLoading ? "Loading models..." : t(`${s}.inheritFromAgent`)} />
diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/thinking-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/thinking-section.tsx index 1a8bec4d..e9395935 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/thinking-section.tsx +++ b/ui/web/src/pages/agents/agent-detail/config-sections/thinking-section.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Select, SelectContent, @@ -7,12 +8,7 @@ import { } from "@/components/ui/select"; import { InfoLabel } from "./config-section"; -const THINKING_LEVELS = [ - { value: "off", label: "Off", description: "No extended thinking" }, - { value: "low", label: "Low", description: "~4K token budget" }, - { value: "medium", label: "Medium", description: "~10-16K token budget" }, - { value: "high", label: "High", description: "~32K token budget" }, -] as const; +const THINKING_LEVEL_KEYS = ["off", "low", "medium", "high"] as const; interface ThinkingSectionProps { value: string; @@ -20,25 +16,33 @@ interface ThinkingSectionProps { } export function ThinkingSection({ value, onChange }: ThinkingSectionProps) { + const { t } = useTranslation("agents"); + const s = "configSections.thinking"; + + const levels = THINKING_LEVEL_KEYS.map((key) => ({ + value: key, + label: t(`${s}.${key}`), + description: t(`${s}.${key}Desc`), + })); + return (
-

Extended Thinking

+

{t(`${s}.title`)}

- Allow the model to reason before responding. Higher levels use more - tokens but produce better results on complex tasks. + {t(`${s}.description`)}

- Thinking Level + {t(`${s}.thinkingLevel`)} onChange({ ...value, profile: v || undefined })} @@ -40,27 +43,27 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
- Allow + {t(`${s}.allow`)} onChange({ ...value, allow: v.length > 0 ? v : undefined })} - placeholder="Select tools to allow..." + placeholder={t(`${s}.selectToolsAllow`)} />
- Deny + {t(`${s}.deny`)} onChange({ ...value, deny: v.length > 0 ? v : undefined })} - placeholder="Select tools to deny..." + placeholder={t(`${s}.selectToolsDeny`)} />
- Also Allow + {t(`${s}.alsoAllow`)} onChange({ ...value, alsoAllow: v.length > 0 ? v : undefined })} - placeholder="Select additional tools..." + placeholder={t(`${s}.selectToolsAlsoAllow`)} />
diff --git a/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx index f1717f59..390953b3 100644 --- a/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx +++ b/ui/web/src/pages/agents/agent-detail/file-sections/file-editor.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Save } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -27,6 +28,8 @@ export function FileEditor({ onSave, headerActions, }: FileEditorProps) { + const { t } = useTranslation("agents"); + if (!fileName) { return (
@@ -34,7 +37,7 @@ export function FileEditor({
{headerActions}
)}
- Select a file to {canEdit ? "edit" : "view"} + {canEdit ? t("files.selectFileToEdit") : t("files.selectFileToView")}
); @@ -56,14 +59,14 @@ export function FileEditor({ {canEdit && ( )}
{loading && !content ? (
- Loading... + {t("files.loading")}
) : (