diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile
new file mode 100644
index 0000000..757021f
--- /dev/null
+++ b/apps/agent/Dockerfile
@@ -0,0 +1,14 @@
+FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
+
+WORKDIR /app
+ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
+
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-dev --no-install-project
+
+COPY main.py ./
+COPY src ./src
+COPY skills ./skills
+
+EXPOSE 8123
+CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8123"]
diff --git a/apps/agent/src/model.py b/apps/agent/src/model.py
index 33725ab..c1e7062 100644
--- a/apps/agent/src/model.py
+++ b/apps/agent/src/model.py
@@ -6,6 +6,7 @@
from langchain.agents.middleware import AgentMiddleware
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.messages import AIMessageChunk
+from langchain_core.runnables import RunnableBinding
from pydantic import Field
from langchain_anthropic import ChatAnthropic
from langchain_core.language_models.chat_models import BaseChatModel
@@ -146,31 +147,48 @@ def _target(self, kwargs):
tool_options = options.pop("_credential_tool_options", {})
if tools is not None:
model = model.bind_tools(tools, **tool_options)
+ # Call the provider's private methods so only this wrapper's run reports
+ # events. A public nested call inherits the graph's callbacks and streams
+ # every token and tool-call delta to AG-UI a second time.
+ if isinstance(model, RunnableBinding):
+ options = {**model.kwargs, **options}
+ model = model.bound
return model, options
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
- message = model.invoke(messages, stop=stop, **options)
- return ChatResult(generations=[ChatGeneration(message=message)])
+ return model._generate(messages, stop=stop, **options)
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
- message = await model.ainvoke(messages, stop=stop, **options)
- return ChatResult(generations=[ChatGeneration(message=message)])
+ return await model._agenerate(messages, stop=stop, **options)
def _stream(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
- for chunk in model.stream(messages, stop=stop, **options):
- yield ChatGenerationChunk(message=as_chunk(chunk))
+ if not streams(model):
+ yield as_generation_chunk(model._generate(messages, stop=stop, **options))
+ return
+ yield from model._stream(messages, stop=stop, **options)
async def _astream(self, messages, stop=None, run_manager=None, **kwargs):
with provider_error_boundary():
model, options = self._target(kwargs)
- async for chunk in model.astream(messages, stop=stop, **options):
- yield ChatGenerationChunk(message=as_chunk(chunk))
+ if not streams(model):
+ yield as_generation_chunk(await model._agenerate(messages, stop=stop, **options))
+ return
+ async for chunk in model._astream(messages, stop=stop, **options):
+ yield chunk
+
+
+def streams(model):
+ return type(model)._stream is not BaseChatModel._stream or type(model)._astream is not BaseChatModel._astream
+
+
+def as_generation_chunk(result):
+ return ChatGenerationChunk(message=as_chunk(result.generations[0].message))
def as_chunk(message):
diff --git a/apps/agent/tests/test_model.py b/apps/agent/tests/test_model.py
index b9fc29f..7b860a6 100644
--- a/apps/agent/tests/test_model.py
+++ b/apps/agent/tests/test_model.py
@@ -84,3 +84,32 @@ def test_missing_provider_key_has_actionable_error(monkeypatch, model, key, valu
monkeypatch.setenv(key, value)
with pytest.raises(ValueError, match=key):
build_model()
+
+
+def test_credential_scoped_model_streams_each_chunk_once():
+ import asyncio
+ from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
+ from langchain_core.messages import AIMessage
+ from langchain_core.runnables import RunnableLambda
+ from src.model import CredentialScopedModel
+
+ class ToolFake(GenericFakeChatModel):
+ def bind_tools(self, tools, **kwargs):
+ return self.bind(tools=tools)
+
+ inner = ToolFake(messages=iter([AIMessage(content="a b c")]))
+ # Bound tools wrap the provider in a RunnableBinding, as in the real agent.
+ wrapped = CredentialScopedModel(fallback=inner).bind_tools([{"name": "t"}])
+
+ # Like a LangGraph node: the parent run's callbacks are inherited by calls inside it.
+ async def node(_):
+ return await wrapped.ainvoke("hi")
+
+ async def stream_events():
+ return [
+ e async for e in RunnableLambda(node).astream_events("x", version="v2")
+ if e["event"] == "on_chat_model_stream"
+ ]
+
+ tokens = [e["data"]["chunk"].content for e in asyncio.run(stream_events())]
+ assert "".join(tokens) == "a b c"
diff --git a/apps/app/src/components/chat/provider-keys.test.tsx b/apps/app/src/components/chat/provider-keys.test.tsx
index 6b69843..204acb0 100644
--- a/apps/app/src/components/chat/provider-keys.test.tsx
+++ b/apps/app/src/components/chat/provider-keys.test.tsx
@@ -97,4 +97,11 @@ describe("memory-only provider keys", () => {
fireEvent.click(screen.getByText("Cancel"));
expect(options?.signal?.aborted).toBe(true);
});
+ it("treats server-held keys as configured when NEXT_PUBLIC_SERVER_KEYS is true", () => {
+ vi.stubEnv("NEXT_PUBLIC_SERVER_KEYS", "true");
+ render();
+ // Gate passes, but no browser headers are sent: the agent uses its own .env keys.
+ expect(screen.getByTestId("active")).toBeEmptyDOMElement();
+ vi.unstubAllEnvs();
+ });
});
diff --git a/apps/app/src/components/chat/provider-keys.tsx b/apps/app/src/components/chat/provider-keys.tsx
index 8840ee6..1309b53 100644
--- a/apps/app/src/components/chat/provider-keys.tsx
+++ b/apps/app/src/components/chat/provider-keys.tsx
@@ -27,7 +27,8 @@ export function ProviderKeysProvider({ children }: { children: ReactNode }) {
return (
{ setKeys(value); setKeysOpen(false); setSession((value) => value + 1); },
clear: () => { setKeys(null); newChat(); },
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..c23b468
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,30 @@
+services:
+ agent:
+ build: apps/agent
+ env_file: apps/agent/.env
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8123/health')"]
+ interval: 30s
+
+ mcp:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.mcp
+ ports:
+ - "127.0.0.1:3100:3100"
+ restart: unless-stopped
+
+ app:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.app
+ args:
+ NEXT_PUBLIC_SERVER_KEYS: "true"
+ environment:
+ LANGGRAPH_DEPLOYMENT_URL: http://agent:8123
+ MCP_SERVER_URL: http://mcp:3100/mcp
+ ports:
+ - "127.0.0.1:${APP_PORT:-3000}:3000"
+ depends_on: [agent, mcp]
+ restart: unless-stopped
diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app
index 0ae73b2..bb0bb2e 100644
--- a/docker/Dockerfile.app
+++ b/docker/Dockerfile.app
@@ -32,6 +32,9 @@ COPY --from=deps /app/packages/design-system/node_modules ./packages/design-syst
# Copy source code
COPY . .
+# Inlined at build time; "true" skips the browser key prompt.
+ARG NEXT_PUBLIC_SERVER_KEYS
+
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp
new file mode 100644
index 0000000..1412ca2
--- /dev/null
+++ b/docker/Dockerfile.mcp
@@ -0,0 +1,19 @@
+# The MCP depends on @repo/design-system, so build from the workspace root.
+FROM node:22-alpine AS builder
+WORKDIR /app
+RUN corepack enable && corepack prepare pnpm@9 --activate
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
+COPY apps/mcp/package.json ./apps/mcp/
+COPY packages/design-system/package.json ./packages/design-system/
+RUN pnpm install --frozen-lockfile --filter open-intelligent-ui-mcp...
+COPY packages/design-system ./packages/design-system
+COPY apps/mcp ./apps/mcp
+RUN pnpm exec turbo run build --filter=open-intelligent-ui-mcp
+
+FROM node:22-alpine
+WORKDIR /app
+COPY --from=builder /app ./
+WORKDIR /app/apps/mcp
+USER node
+EXPOSE 3100
+CMD ["node", "dist/index.js"]
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 73fa6fc..ba65efb 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -40,9 +40,20 @@ Optional settings belong in `apps/app/.env.local` or the frontend process enviro
| `RATE_LIMIT_ENABLED` | `false` | Per-IP runtime rate limit |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window |
| `RATE_LIMIT_MAX` | `40` | Requests per window |
+| `NEXT_PUBLIC_SERVER_KEYS` | Unset | Build-time; `true` skips the browser key prompt when the agent has its own keys |
The root [.env.example](../.env.example) documents both services' settings; placing variables only in a root `.env` is not the setup described here.
+## Run with Docker Compose
+
+To self-host the app, agent, and MCP server with the agent's own keys, fill in `apps/agent/.env` (see above) and run:
+
+```bash
+docker compose up -d
+```
+
+The app listens on `http://localhost:3000` (set `APP_PORT` to change it) and the MCP server on `http://localhost:3100/mcp`; both bind to `127.0.0.1`. The compose build sets `NEXT_PUBLIC_SERVER_KEYS=true`, so chat uses the agent's `.env` keys without asking for browser keys. After editing `apps/agent/.env`, run `docker compose up -d agent` to apply it.
+
## Start and verify
```bash