# Technical Documentation: andrewyng/aisuite

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/andrewyng/aisuite)

### Overview (`README.md`)

> 
> ## OpenWorker
> **A desktop AI coworker, built on aisuite — now in its own repository: [andrewyng/openworker](https://github.com/andrewyng/openworker).**
>
> OpenWorker chats, does deep research, and carries out real tasks on your computer — reading files with
> permission, connecting to Slack/email, producing PDFs, documents, and spreadsheets, and running scheduled
> automations. Bring your own API key (OpenAI, Anthropic, Google) or run fully local with Ollama; your data
> stays on your machine.
>
> [**⬇ Download for macOS**](https://github.com/andrewyng/openworker/releases/latest/download/OpenWorker-macos-arm64.dmg) <sub>macOS 13+ (Apple Silicon)</sub> &nbsp;·&nbsp; [**⬇ Download for Windows**](https://github.com/andrewyng/openworker/releases/latest/download/OpenWorker-windows-setup.exe) <sub>Windows 10/11 (x64)</sub> &nbsp;·&nbsp; [**Quickstart**](https://github.com/andrewyng/openworker#readme)
>
> *OpenWorker development has moved to the new repo. A historical snapshot of its source remains in [`openworker-archive/`](openworker-archive/README.md).*

<br>

#  aisuite

[](https://pypi.org/project/aisuite/)
[](https://github.com/psf/black)

`aisuite` is a lightweight Python library for building with LLMs, in two layers: a unified **Chat Completions API** across providers, and an **Agents API** with tools and toolkits on top. aisuite also powers **OpenWorker**, a desktop AI coworker developed in [its own repository](https://github.com/andrewyng/openworker):

```text
┌───────────────────────────────────────────────┐
│          OpenWorker  (separate repo)          │   agent harness for doing everyday tasks
├───────────────────────────────────────────────┤
│        Agents API  ·  Toolkits  ·  MCP        │   build agents across multiple LLMs
├───────────────────────────────────────────────┤
│             Chat Completions API              │   one API across multiple LLM providers
├────────┬───────────┬────────┬────────┬────────┤
│ OpenAI │ Anthropic │ Google │ Ollama │ Others │
└────────┴───────────┴────────┴────────┴────────┘
```

* **[Chat Completions API](#chat-completions)** — a unified, OpenAI-style interface for *OpenAI, Anthropic, Google, Mistral, Hugging Face, AWS, Cohere, Ollama, OpenRouter, Requesty*, and more. Swap providers by changing one string.
* **[Agents API · Toolkits · MCP](#agents)** — give models real Python functions as tools, run multi-turn loops, attach ready-made toolkits (files, git, shell) or any MCP server, and govern it all with tool policies.
* **[OpenWorker](https://github.com/andrewyng/openworker)** — a desktop AI coworker built using aisuite, shipped as an app for everyday tasks. Developed in its own repository.

---

## Installation

Install the base package, or include the SDKs of the providers you plan to use:

```shell
pip install aisuite               # base package, no provider SDKs
pip install 'aisuite[anthropic]'  # with a specific provider's SDK
pip install 'aisuite[all]'        # with all provider SDKs
```

You'll also need API keys for the providers you call — the [Chat Completions quickstart](docs/chat-completions-quickstart.md) covers key setup and your first calls.

Looking for the OpenWorker desktop app? Downloads are on [its releases page](https://github.com/andrewyng/openworker/releases/latest).

---

<a id="chat-completions"></a>
## Chat Completions — one API across providers

The chat API provides a high-level abstraction for model interactions. It supports all core parameters (`temperature`, `max_tokens`, `tools`, etc.) in a provider-agnostic way, and standardizes request and response structures so you can focus on logic rather than SDK differences.

Model names use the format `<provider>:<model-name>`; aisuite routes the call to the right provider with the right parameters:

```python
import aisuite as ai
client = ai.Client()

models = ["openai:gpt-4o", "anthropic:claude-3-5-sonnet-20240620"]

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

for model in models:
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.75
    )
    print(response.choices[0].message.content)
```

**→ Quickstart:** [docs/chat-completions-quickstart.md](docs/chat-completions-quickstart.md) — install, key setup, local models, and more examples.

### Streaming

Pass `stream=True` to get an iterator of OpenAI-shaped chunks from any supporting provider (OpenAI, Anthropic, Ollama, and OpenAI-compatible endpoints) — the same loop works across all of them:

```python
for chunk in client.chat.completions.create(model=model, messages=messages, stream=True):
    print(chunk.choices[0].delta.content or "", end="", flush=True)
```

The async variant is `await client.chat.completions.acreate(..., stream=True)`, iterated with `async for`. Tool calls stream too: schema dicts and callables are passed to the model as usual, and the chunks carry incremental `delta.tool_calls` fragments for you to assemble and execute (streaming is manual tool calling — it can't be combined with `max_turns`).

---

<a id="agents"></a>
## Agents — give models real tools

aisuite turns tool calling into a one-liner: pass plain Python functions and it generates the schemas, executes the calls, and feeds results back to the model.

### Tool calling with `max_turns`

```python
def will_it_rain(location: str, time_of_day: str):
    """Check if it will rain in a location at a given time today.

    Args:
        location (str): Name of the city
        time_of_day (str): Time of the day in HH:MM format.
    """
    return "YES"

client = ai.Client()
response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=[{
        "role": "user",
        "content": "I live in San Francisco. Can you check for weather "
                   "and plan an outdoor picnic for me at 2pm?"
    }],
    tools=[will_it_rain],
    max_turns=2  # Maximum number of back-and-forth tool calls
)
print(response.choices[0].message.content)
```

With `max_turns` set, aisuite sends your message, executes any tool calls the model requests, returns the results to the model, and repeats until the conversation completes. `response.choices[0].intermediate_messages` carries the full tool interaction history if you want to continue the conversation.

Prefer full manual control? Omit `max_turns` and pass OpenAI-format JSON tool specs — aisuite returns the model's tool-call requests and you run the loop yourself. See `examples/tool_calling_abstraction.ipynb` for both styles.

### The Agents API

For longer-running, structured work there is a first-class Agents API: declare an agent once, run it with a `Runner`, and attach **toolkits** — prebuilt, sandboxed tool families for files, git, and shell:

```python
import aisuite as ai
from aisuite import Agent, Runner

agent = Agent(
    name="repo-helper",
    model="anthropic:claude-sonnet-4-6",
    instructions="You are a careful repo assistant. Use your tools to answer from the code.",
    tools=[*ai.toolkits.files(root="."), *ai.toolkits.git(root=".")],
)

result = Runner.run(agent, "What changed in the last commit? Summarize in 3 bullets.")
print(result.final_output)
```

The Agents API also gives you the pieces a production harness needs:

* **Tool policies** — `RequireApprovalPolicy`, allow/deny lists, or your own callable deciding which tool calls run.
* **State stores** — persist and resume runs (in-memory, file, or Postgres) and continue conversations across processes.
* **Artifacts & tracing** — capture what an agent produced and every step it took along the way.

### MCP tools

aisuite natively supports the [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro), so any MCP server's tools can be handed to a model without boilerplate (`pip install 'aisuite[mcp]'`):

```python
client = ai.Client()
response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=[{"role": "user", "content": "List the files in the current directory"}],
    tools=[{
        "type": "mcp",
        "name": "filesystem",
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"]
    }],
    max_turns=3
)
print(response.choices[0].message.content)
```

For reusable connections, security filters, and tool prefixing, use the explicit `MCPClient`.

**→ Quickstart:** [docs/agents-quickstart.md](docs/agents-quickstart.md) — manual tool handling, the full Agents API, policies, state stores, and MCP in depth.

---

## Extending aisuite: Adding a Provider

New providers can be added by implementing a lightweight adapter. The system uses a naming convention for discovery:

| Element         | Convention                         |
| --------------- | ---------------------------------- |
| **Module file** | `<provider>_provider.py`           |
| **Class name**  | `<Provider>Provider` (capitalized) |

Example:

```python
# providers/openai_provider.py
class OpenaiProvider(BaseProvider):
    ...
```

This convention ensures consistency and enables automatic loading of new integrations.

---

## Contributing

Contributions are welcome. Please review the [Contributing Guide](https://github.com/andrewyng/aisuite/blob/main/CONTRIBUTING.md) and join our [Discord](https://discord.gg/T6Nvn8ExSb) for discussions.

---

## License

Released under the **MIT License** — free for commercial and non-commercial use.

---

---

### Overview (`aisuite-js/README.md`)

# AISuite

AISuite is a unified TypeScript library that provides a single, consistent interface for interacting with multiple Large Language Model (LLM) providers. The library uses OpenAI's API format as the standard interface while supporting OpenAI and Anthropic Claude.

npm pacakge - `npm i aisuite`

## Features

- **Unified API**: Single interface compatible with OpenAI's API structure
- **Multi-Provider Support**: Currently supports OpenAI and Anthropic
- **Provider Selection**: Use `provider:model` format (e.g., `openai:gpt-4o`, `anthropic:claude-3-haiku-20240307`)
- **Tool Calling**: Transparent tool/function calling across all providers
- **Streaming**: Real-time streaming responses with consistent API
- **Type Safety**: Full TypeScript support with comprehensive type definitions
- **Error Handling**: Unified error handling across providers
- **Speech-to-Text**: Automatic Speech Recognition (ASR) support with multiple providers (OpenAI Whisper, Deepgram)

## Installation

```bash
npm install aisuite
```

## Quick Start

```typescript
import { Client } from 'aisuite';

const client = new Client({
  openai: { 
    apiKey: process.env.OPENAI_API_KEY,    
  },
  anthropic: { apiKey: process.env.ANTHROPIC_API_KEY },
  deepgram: { apiKey: process.env.DEEPGRAM_API_KEY },
});

// Use any provider with identical interface
const response = await client.chat.completions.create({
  model: 'openai:gpt-4o',
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'Hello!' }
  ],
});

console.log(response.choices[0].message.content);
```

## Usage Examples

### Basic Chat Completion

```typescript
// OpenAI
const openaiResponse = await client.chat.completions.create({
  model: 'openai:gpt-4o',
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'What is TypeScript?' }
  ],
  temperature: 0.7,
  max_tokens: 1000,
});

// Anthropic - exact same interface
const anthropicResponse = await client.chat.completions.create({
  model: 'anthropic:claude-3-haiku-20240307',
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: 'What is TypeScript?' }
  ],
  temperature: 0.7,
  max_tokens: 1000,
});
```

### Tool/Function Calling

```typescript
const tools = [
  {
    type: 'function' as const,
    function: {
      name: 'get_weather',
      description: 'Get current weather for a location',
      parameters: {
        type: 'object',
        properties: {
          location: { type: 'string', description: 'City name' }
        },
        required: ['location']
      }
    }
  }
];

// Works identically across all providers
const response = await client.chat.completions.create({
  model: 'anthropic:claude-3-haiku-20240307',
  messages: [{ role: 'user', content: 'What\'s the weather in NYC?' }],
  tools,
  tool_choice: 'auto'
});

if (response.choices[0].message.tool_calls) {
  console.log('Tool calls:', response.choices[0].message.tool_calls);
}
```

### Streaming Responses

```typescript
const stream = await client.chat.completions.create({
  model: 'openai:gpt-4o',
  messages: [{ role: 'user', content: 'Tell me a story' }],
  stream: true
});

// TypeScript: cast to AsyncIterable<ChatCompletionChunk>
for await (const chunk of stream as AsyncIterable<ChatCompletionChunk>) {
  process.stdout.write(chunk.choices[0]?.delta?.content || '');
}
```

### Streaming with Abort Controller

```typescript
const controller = new AbortController();

// Abort after 5 seconds
setTimeout(() => controller.abort(), 5000);

const stream = await client.chat.completions.create({
  model: 'anthropic:claude-3-haiku-20240307',
  messages: [{ role: 'user', content: 'Write a long story' }],
  stream: true
}, { signal: controller.signal });

try {
  for await (const chunk of stream as AsyncIterable<ChatCompletionChunk>) {
    process.stdout.write(chunk.choices[0]?.delta?.content || '');
  }
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Stream aborted');
  }
}
```

### Speech-to-Text Transcription

```typescript
// Initialize client with audio support for OpenAI
const client = new Client({
  openai: { 
    apiKey: process.env.OPENAI_API_KEY,    
  },
  deepgram: { apiKey: process.env.DEEPGRAM_API_KEY }
});

// Using Deepgram
const deepgramResponse = await client.audio.transcriptions.create({
  model: 'deepgram:nova-2',
  file: audioBuffer,  // Buffer containing audio data
  language: 'en-US',
  timestamps: true,
  word_confidence: true,
  speaker_labels: true,
});

// Using OpenAI Whisper
const openaiResponse = await client.audio.transcriptions.create({
  model: 'openai:whisper-1',
  file: audioBuffer,
  language: 'en',
  response_format: 'verbose_json',
  temperature: 0,
  timestamps: true,
});

console.log('Transcribed Text:', openaiResponse.text);
console.log('Words with timestamps:', openaiResponse.words);
```

### Error Handling

```typescript
import { AISuiteError, ProviderNotConfiguredError } from 'aisuite';

try {
  const response = await client.chat.completions.create({
    model: 'invalid:model',
    messages: [{ role: 'user', content: 'Hello' }]
  });
} catch (error) {
  if (error instanceof ProviderNotConfiguredError) {
    console.error('Provider not configured:', error.message);
  } else if (error instanceof AISuiteError) {
    console.error('AISuite error:', error.message, error.provider);
  } else {
    console.error('Unknown error:', error);
  }
}
```

## API Reference

### Client Configuration

```typescript
const client = new Client({
  openai?: {
    apiKey: string;
    baseURL?: string;
    organization?: string;    
  },
  anthropic?: {
    apiKey: string;
    baseURL?: string;
  },
  deepgram?: {
    apiKey: string;
    baseURL?: string;
  }
});
```

### Chat Completion Request

All providers use the standard OpenAI chat completion format:

```typescript
interface ChatCompletionRequest {
  model: string;              // "provider:model" format
  messages: ChatMessage[];
  tools?: Tool[];
  tool_choice?: ToolChoice;
  temperature?: number;
  max_tokens?: number;
  stop?: string | string[];
  stream?: boolean;
}
```

### Transcription Request

All ASR providers use a standard transcription request format with additional provider-specific parameters:

```typescript
interface TranscriptionRequest {
  model: string;              // "provider:model" format
  file: Buffer;              // Audio file as Buffer
  language?: string;         // Language code (e.g., "en", "en-US")
  timestamps?: boolean;      // Include word-level timestamps
  [key: string]: any;        // Additional provider-specific parameters:
                            // For OpenAI: See https://platform.openai.com/docs/api-reference/audio/createTranscription
                            // For Deepgram: See https://developers.deepgram.com/reference/speech-to-text-api/listen  
}
```

### Helper Methods

```typescript
// List all configured providers (including ASR)
client.listProviders(); // ['openai', 'anthropic']
client.listASRProviders(); // ['deepgram', 'openai']

// Check if a provider is configured
client.isProviderConfigured('openai'); // true
client.isASRProviderConfigured('deepgram'); // true
```

## Current Limitations

- Only OpenAI and Anthropic providers are currently supported for chat (Gemini, Mistral, and Bedrock coming soon)
- Tool calling requires handling tool responses manually
- Streaming tool calls require manual accumulation of arguments
- ASR support is limited to OpenAI Whisper (requires explicit audio configuration) and Deepgram
- Some provider-specific ASR features might require using provider-specific parameters

## Development

```bash
# Install dependencies
npm install

# Build the project
npm run build

# Run tests
npm test

# Run examples
#Run basic usage example only:
npm run example:basic
# Run tool calling example only:
npm run example:tools
# Run the full test suite:
npm run test:examples
```

## License

MIT

---

### Overview (`guides/README.md`)

# Provider guides 

These guides give directions for obtaining API keys from different providers. 

Here are the instructions for:
- [Anthropic](anthropic.md) 
- [AWS](aws.md)
- [Azure](azure.md) 
- [Cohere](cohere.md)
- [Gemini](gemini.md)
- [Google](google.md)
- [Groq](groq.md)
- [Hugging Face](huggingface.md)
- [Mistral](mistral.md)
- [OpenAI](openai.md)
- [Featherless](featherless.md)
- [SambaNova](sambanova.md)
- [xAI](xai.md)
- [DeepSeek](deepseek.md)
- [Eden AI](edenai.md)

For locally hosted models using `Ollama` or `LM Studio`, follow these instructions:
- [Ollama](ollama.md)
- [LM Studio](lmstudio.md)

Unless otherwise stated, these guides have not been endorsed by the providers. 

We also welcome additional [contributions](../CONTRIBUTING.md).

---

### Overview (`openworker-archive/README.md`)

# OpenWorker archive

This directory is a frozen snapshot of the OpenWorker source that previously
lived in this repository under `platform/`.

OpenWorker is now developed in its own repository:
[andrewyng/openworker](https://github.com/andrewyng/openworker).

## Archive policy

- Do not develop new OpenWorker features in this directory.
- Send OpenWorker changes to the active OpenWorker repository.
- Extract generally useful model, agent, tool, state, artifact, or tracing
  functionality into aisuite instead of extending this snapshot.
- This archive is not part of the aisuite package, its supported public API, or
  its active test and release workflows.

The former OpenWorker GitHub release workflow is retained here as
`release-workflow.yml` for historical reference. Git history preserves the
snapshot's development history and its original `platform/` path.

---

### Overview (`examples/chat-ui/README.md`)

# Chat UI

This is a simple chat UI built using Streamlit. It uses the `aisuite` library to power the chat.

You will need to install streamlit to run this example.

```bash
pip install streamlit
```

You will also need to create a `config.yaml` file in the same directory as the `chat.py` file. An example config file has been provided. You need to set environment variables for the API keys and other configuration for the LLMs you want to use. Place a .env file in this directory since `chat.py` will look for it.

In config.yaml, you can specify the LLMs you want to use in the chat. The chat UI will then display all these LLMs and you can select the one you want to use.

To run the app, simply run the following command in your terminal:

```bash
streamlit run chat.py
```

You can choose different LLMs by ticking the "Comparison Mode" checkbox. Then select the two LLMs you want to compare.
Here are some sample queries you can try:

```
User: "What is the weather in Tokyo?"
```

```
User: "Write a poem about the weather in Tokyo."
```

```
User: "Write a python program to print the fibonacci sequence."
Assistant: "-- Content from LLM 1 --"
User: "Write test cases for this program."
```

---

### Overview (`tests/mcp/README.md`)

# MCP Integration Tests

This directory contains integration tests for aisuite's MCP (Model Context Protocol) support.

## Prerequisites

To run these tests, you need:

1. **Node.js and npx** - Required to run the Anthropic filesystem MCP server
   - Install from: https://nodejs.org/
   - Verify with: `npx --version`

2. **Python test dependencies**:
   ```bash
   pip install pytest pytest-asyncio python-dotenv
   ```

3. **MCP package** (should already be installed if you have aisuite[mcp]):
   ```bash
   pip install 'aisuite[mcp]'
   ```

4. **Environment variables** (for e2e tests that mock LLM calls):
   Create a `.env` file in the project root with your API keys:
   ```bash
   OPENAI_API_KEY=your-key-here
   ANTHROPIC_API_KEY=your-key-here
   EXA_API_KEY=your-key-here  # Optional: for Exa MCP tests
   ```
   Note: E2E tests mock LLM responses, so API keys won't be charged, but providers validate keys on initialization.

## Running Tests

### Run all MCP integration tests (mocked LLM, free):
```bash
pytest tests/mcp/ -v -m "integration and not llm"
```

### Run specific test file:
```bash
# MCPClient tests
pytest tests/mcp/test_client.py -v -m integration

# End-to-end tests (mocked LLM)
pytest tests/mcp/test_e2e.py -v -m integration

# Real LLM tests with stdio (⚠️ costs money, requires API keys)
pytest tests/mcp/test_llm_e2e.py -v -m llm

# Real LLM tests with HTTP (⚠️ costs money, requires API keys)
pytest tests/mcp/test_http_llm_e2e.py -v -m llm
```

### Run ONLY real LLM tests (⚠️ costs ~$0.50):
```bash
pytest tests/mcp/ -v -m llm
```

### Run ALL tests including LLM (⚠️ costs money):
```bash
pytest tests/mcp/ -v -m integration
```

### Run a specific test:
```bash
pytest tests/mcp/test_client.py::TestMCPClientConnection::test_connect_to_filesystem_server -v
```

### Skip integration tests (if no Node.js):
```bash
pytest tests/mcp/ -v -m "not integration"
```

## Test Structure

### `test_client.py` - MCPClient Integration Tests
Tests the `MCPClient` class with a real MCP server:
- Connection to Anthropic filesystem server
- Listing tools
- Calling tools
- Tool filtering (`allowed_tools`)
- Tool prefixing (`use_tool_prefix`)
- `from_config()` method
- Context manager support

### `test_e2e.py` - End-to-End Tests (Mocked LLM)
Tests the complete flow with `client.chat.completions.create()`:
- Config dict format
- Mixing MCP configs with Python functions
- Multiple MCP servers with prefixing
- Automatic cleanup
- Error handling
- **Note:** LLM responses are mocked, so no API calls are made

### `test_llm_e2e.py` - Real LLM End-to-End Tests with stdio (⚠️ Costs Money)
Tests with **actual API calls** to verify stdio MCP works with real LLMs:
- OpenAI GPT-4o reading files via stdio MCP
- Anthropic Claude reading files via stdio MCP
- Mixed tools (stdio MCP + Python functions)
- Multiple MCP servers with prefixing
- **Uses:** `@modelcontextprotocol/server-filesystem` (stdio)
- **Note:** These tests make real API calls (~$0.05-0.10 per test)
- **Marked with:** `@pytest.mark.llm`
- **Skipped if:** API keys not present in .env

### `test_http_llm_e2e.py` - Real LLM End-to-End Tests with HTTP (⚠️ Costs Money)
Tests with **actual API calls** to verify HTTP MCP works with real LLMs:
- OpenAI GPT-4o using HTTP MCP tools (Context7 and Exa)
- Anthropic Claude using HTTP MCP tools (Context7 and Exa)
- Mixed tools (HTTP MCP + Python functions)
- Config dict format with HTTP transport
- Custom headers support (including Authorization headers for Exa)
- **Uses:**
  - Context7 HTTP MCP server (`https://mcp.context7.com/mcp`)
    - Tools: `resolve-library-id`, `get-library-docs` (library documentation)
  - Exa HTTP MCP server (`https://mcp.exa.ai/mcp`)
    - Tools: `web_search_exa`, `get_code_context_exa` (web search and code context)
    - Requires: EXA_API_KEY in .env
- **Note:** These tests make real API calls (~$0.05-0.10 per test)
- **Marked with:** `@pytest.mark.llm`
- **Skipped if:** API keys not present in .env

### `conftest.py` - Test Fixtures
- `temp_test_dir` - Creates temp directory with test files
- `skip_if_no_npx` - Skips tests if npx not available

## What Gets Tested

### stdio Transport Tests
Use the **real** `@modelcontextprotocol/server-filesystem` MCP server from Anthropic, which:
- Provides file system access tools (read_file, write_file, list_directory, etc.)
- Is installed automatically via `npx -y @modelcontextprotocol/server-filesystem`
- Runs in a temporary test directory for isolation

### HTTP Transport Tests
Use **real** HTTP MCP servers:

1. **Context7** (`https://mcp.context7.com/mcp`):
   - Provides library documentation tools
   - Tools: `resolve-library-id`, `get-library-docs`
   - No installation required (hosted service)
   - No authentication required (optional API key for higher rate limits)

2. **Exa** (`https://mcp.exa.ai/mcp`):
   - Provides web search and code context tools
   - Tools: `web_search_exa`, `get_code_context_exa`, `deep_researcher`
   - No installation required (hosted service)
   - Requires: EXA_API_KEY (via Authorization header)

The tests verify:
1. ✅ Connection to real MCP servers (stdio and HTTP)
2. ✅ Tool discovery and schema parsing
3. ✅ Tool execution and result handling
4. ✅ Config dict → callable conversion
5. ✅ Tool filtering and prefixing
6. ✅ Integration with aisuite's tool system
7. ✅ Proper resource cleanup
8. ✅ Error handling
9. ✅ HTTP transport with headers and timeout

## CI/CD

### GitHub Actions
If running in CI without Node.js:
```yaml
- name: Run tests
  run: pytest tests/mcp/ -v -m "not integration"
```

With Node.js:
```yaml
- name: Setup Node.js
  uses: actions/setup-node@v3
  with:
    node-version: '18'

- name: Run integration tests
  run: pytest tests/mcp/ -v -m integration
```

## Notes

- Tests are marked with `@pytest.mark.integration` to allow selective running
- Most tests use mocking for LLM API calls to avoid costs
- Real LLM tests are marked with `@pytest.mark.llm` and can be skipped
- Each test creates isolated temp directories for file operations
- MCP servers are started fresh for each test
- Cleanup is automatic via fixtures and context managers

## Test Markers

- `@pytest.mark.integration` - All MCP tests (includes both mocked and real LLM)
- `@pytest.mark.llm` - Real LLM tests only (makes actual API calls, costs money)

To run tests without LLM costs:
```bash
pytest tests/mcp/ -v -m "integration and not llm"
```

## Troubleshooting

**Error: "npx not found"**
- Install Node.js from https://nodejs.org/

**Error: "MCP package not installed"**
- Run: `pip install 'aisuite[mcp]'`

**Tests hang or timeout**
- Check Node.js/npx is working: `npx --version`
- Check MCP server can be installed: `npx -y @modelcontextprotocol/server-filesystem --help`

**Import errors**
- Make sure you're running from the project root
- Install test dependencies: `pip install pytest pytest-asyncio`

---

### Overview (`aisuite-js/examples/chat-app/README.md`)

# AISuite Chat App

A modern React TypeScript chat application built with AISuite, allowing you to chat with multiple AI models and compare their responses in real-time.

## Features

- **Multi-Provider Support**: Chat with OpenAI, Anthropic, Groq, and Mistral models
- **Comparison Mode**: Compare responses from two different AI models side-by-side
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
- **Real-time Chat**: Instant messaging with AI models
- **API Key Management**: Secure storage and management of API keys
- **Error Handling**: Comprehensive error handling and user feedback
- **TypeScript**: Full type safety throughout the application

## Prerequisites

- Node.js 18+ 
- npm or yarn
- API keys for the AI providers you want to use:
  - OpenAI API key
  - Anthropic API key
  - Groq API key
  - Mistral API key

## Installation

1. Clone the repository and navigate to the chat app directory:
```bash
cd aisuite-js/chat-app
```

2. Install dependencies:
```bash
npm install
```

3. Start the development server:
```bash
npm run dev
```

4. Open your browser and navigate to `http://localhost:3000`

## Configuration

### API Keys

1. Click the "Configure API Keys" button in the header
2. Enter your API keys for the providers you want to use
3. Click "Save" to store the configuration

The app will automatically save your API keys to localStorage for future use.

### Supported Models

The app comes pre-configured with the following models:

**OpenAI:**
- GPT-4o
- GPT-4o Mini

**Anthropic:**
- Claude 3.5 Sonnet
- Claude 3 Haiku

**Groq:**
- Llama 3.1 8B
- Mixtral 8x7B

**Mistral:**
- Mistral 7B
- Mistral Large

## Usage

### Basic Chat

1. Configure your API keys
2. Select a model from the dropdown
3. Type your message and press Enter or click Send
4. View the AI response

### Comparison Mode

1. Enable "Comparison Mode" checkbox
2. Select two different models
3. Send a message to see responses from both models side-by-side
4. Compare the different responses and capabilities

### Chat Management

- **Reset Chat**: Click the reset button to clear all chat history
- **Model Switching**: Change models at any time during the conversation
- **Error Handling**: The app displays clear error messages for API issues

## Sample Queries

Try these sample queries to test the different models:

```
"What is the weather in Tokyo?"
```

```
"Write a poem about the weather in Tokyo."
```

```
"Write a python program to print the fibonacci sequence."
```

```
"Write test cases for this program."
```

## Development

### Project Structure

```
src/
├── components/          # React components
│   ├── ApiKeyModal.tsx
│   ├── ChatContainer.tsx
│   ├── ChatInput.tsx
│   ├── ChatMessage.tsx
│   └── ModelSelector.tsx
├── config/             # Configuration files
│   └── llm-config.ts
├── services/           # Business logic
│   └── aisuite-service.ts
├── types/              # TypeScript type definitions
│   └── chat.ts
├── App.tsx            # Main application component
├── main.tsx           # Application entry point
└── index.css          # Global styles
```

### Available Scripts

- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run lint` - Run ESLint

### Adding New Models

To add new models, edit `src/config/llm-config.ts`:

```typescript
export const configuredLLMs: LLMConfig[] = [
  // ... existing models
  {
    name: "Your New Model",
    provider: "provider-name",
    model: "model-name"
  }
];
```

### Styling

The app uses Tailwind CSS for styling. The design system includes:

- Light and dark mode support
- Responsive design
- Custom scrollbars
- Loading animations
- Error states

## Technologies Used

- **React 18** - UI framework
- **TypeScript** - Type safety
- **Vite** - Build tool and dev server
- **Tailwind CSS** - Styling
- **Lucide React** - Icons
- **AISuite** - AI provider abstraction

## Browser Support

- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+

## Contributing

1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request

## License

MIT License - see the main repository for details.

## Support

For issues and questions:
- Check the [AISuite documentation](https://github.com/andrewyng/aisuite)
- Open an issue in the repository
- Check the console for error messages

## Security Notes

- API keys are stored in localStorage (client-side only)
- No API keys are sent to any server except the AI providers
- Consider using environment variables for production deployments

---

### Overview (`cli/py/aisuite-code-cli/README.md`)

# aisuite-code

`aisuite-code` is a local coding agent CLI built on the aisuite agent framework.
It is intentionally small for now: a conversation loop, cwd-scoped file tools,
approval-gated shell/write tools, and trace output that can be inspected in the
aisuite runs viewer.

Install and run from this package directory:

```bash
cd cli/py/aisuite-code-cli
python3 -m poetry install
python3 -m poetry run aisuite-code --cwd /path/to/project
```

From the repository root, use the convenience shim:

```bash
./scripts/aisuite-code --cwd /path/to/project
```

The CLI package depends on the OpenAI provider by default because the default
model is `openai:gpt-4o-mini`. Set `OPENAI_API_KEY` in your shell before running
the CLI, or source the repository `.env` file first.

Useful commands inside the CLI:

```text
/help
/viewer
/viewer start
/status
/clear
/exit
```

By default the CLI can read and write within `--cwd`, but write operations still
require approval. Shell commands are limited to common coding commands unless
`--allow-shell-all` is supplied.

The main agent also has a read-only reviewer subagent available as
`review_changes(input: str)`. It can call the reviewer when the user asks for a
review or when a second opinion would help after substantial edits. The reviewer
subagent can inspect files, but it cannot edit files or run commands. Use
`--no-reviewer` to disable it.

For a short hands-on flow, see [TRY_IT.md](TRY_IT.md).

---

### Overview (`openworker-archive/surfaces/gui/README.md`)

# coworker GUI (React + Tauri)

A thin client of the coworker server (OpenAI-compatible API + WS event/approval stream).
Same codebase runs in a browser (dev) and as the OpenWorker desktop app.

## First time: bootstrap the Python backend

A fresh checkout has no server to run — create the venv both flows below expect:

```bash
bash platform/packaging/setup_dev_env.sh   # → platform/.venv (server + this repo's aisuite)
```

## Run it (browser, two terminals)

1. **Start the server** (needs a model key, e.g. `OPENAI_API_KEY`, in the environment —
   or add one later in the app's Settings):
   ```bash
   cd platform
   ./.venv/bin/coworker-server --cwd /path/to/your/project --port 8765
   ```
2. **Start the UI:**
   ```bash
   cd platform/surfaces/gui
   npm install      # first time
   npm run dev      # → http://localhost:5173
   ```

Open http://localhost:5173. The UI talks to `http://127.0.0.1:8765` (override with
`VITE_COWORKER_HTTP` / `VITE_COWORKER_WS`).

## Run the desktop app from source

The Tauri shell wraps the same UI and supervises the Python server itself — no separate
terminal. It needs the Rust toolchain (`rustup`) plus the venv from the bootstrap step;
in dev it finds the server at `platform/.venv/bin/coworker-server` automatically (a
packaged sidecar binary is only produced by the release scripts in `platform/packaging/`).

```bash
cd platform/surfaces/gui
npm install        # first time
npm run tauri dev  # builds the shell, launches the window, starts the server
```

## Tests

```bash
npx tsc --noEmit && npx vitest run   # typecheck + unit
npx playwright test                  # hermetic e2e (mocked /v1 + WS, no Python needed)
```

---

### Overview (`openworker-archive/surfaces/gui/e2e/README.md`)

# E2E tests (Playwright)

End-to-end regression tests for the GUI. They drive the real app in Chromium but are **hermetic**:
every `/v1` request and the event WebSocket are mocked at the network layer, so tests need **no
Python backend**, run deterministically, and never mutate real state.

## Run

```bash
npm run e2e          # headless
npm run e2e:ui       # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts   # a single spec
```

## Live smoke (not CI)

`npm run e2e:live` runs `e2e-live/` (separate `playwright.live.config.ts`) against the **real**
backend on :8765. Two flavors, both skip cleanly when the backend is down:

- **API-shape smoke** (`api-smoke.spec.ts`) — no model tokens, no creds. Asserts `/v1/health` and
  `/v1/providers` return the shapes the GUI reads, catching drift between the mocks and the real
  backend. Cheap enough to run anytime the sidecar is up.
- **Full vertical** (`fib.spec.ts`, …) — asks a fresh Cowork session to produce `fib.md` and
  verifies the file lands on disk. Needs a model configured, is nondeterministic, and costs a few
  tokens per run. Exercises the vertical the hermetic specs mock: model wiring, the tool/approval
  loop, file I/O, and WebSocket streaming.

The config (`playwright.config.ts`) starts the Vite dev server on port **5199** (dedicated, so it
won't clash with a running `npm run dev` on 5173) and reuses it if already up.

## How the mock works

`e2e/fixtures.ts` exports a `test` whose `page` has `mockApi()` installed before navigation:

- `page.route("**/v1/**", …)` dispatches by pathname + method to fixtures whose shapes mirror the
  real backend (captured from a live server). Unknown endpoints return an empty-but-valid body.
- Mutations are held in per-test in-memory state so they reflect through the real UI on re-fetch:
  sessions (archive/rename/delete), personas (enable/surface/delete — enable implies surface,
  matching the backend), inbox items + the routing binding, roots, channel subscriptions.
- The session WebSocket (`routeWebSocket`) is a **scripted fake agent** speaking the real
  `{type, data}` event protocol: `ready` on connect; `user_message` → `turn_start` → deltas →
  `assistant_message "Echo: <text>"` → `turn_done`; a message containing **"run a tool"** emits
  `tool_proposed` + `permission_required` and suspends until the client's `approval` decision
  arrives. This runs the production send/stream/approve code paths with zero model cost.
- Seed data worth knowing: the pinned session "Draft the launch note" is the newest (boot-resume
  target); 7 unpinned "Weekly plan N" cowork sessions exercise the sidebar peek cap; two pending
  Inbox items (approval on cowork, question on ops) drive the Inbox filters; `acme-notes` is a
  disabled non-builtin persona for enable/delete flows. Providers are seeded in three states
  (OpenAI configured+used, Anthropic configured-unused, Z AI unconfigured w/ prefilled endpoint) —
  `POST /v1/providers` flips `configured` on save, `/verify` fails on a key containing "bad". One
  automation ("Daily AI News") with a running run — `POST .../run` appends a run, `PATCH`/`DELETE`
  toggle and remove.

## Adding a spec

```ts
import { test, expect } from "./fixtures";

test("…", async ({ page }) => {
  await page.goto("/");
  // interact + assert
});
```

If a flow reads a new endpoint, add its fixture + a route branch in `fixtures.ts` — the catch-all
returns `{}`, which will crash components that expect arrays (e.g. persona `recommends`). Prefer
`getByRole`, but note some controls (the Sources bar, the ✕ remove) take their accessible name from
inner content — target those with `getByTitle`/`getByLabel`.
```

---

### Agents Quickstart (`docs/agents-quickstart.md`)

# Agents quickstart

Build agents across multiple LLMs: pass real Python functions as tools, run multi-turn loops, attach toolkits and MCP servers, and govern execution with policies.

Start with the [Chat Completions quickstart](chat-completions-quickstart.md) if you haven't installed aisuite and set up keys yet.

## Tool calling with `max_turns`

Pass plain Python functions — aisuite generates the schemas from signatures and docstrings, executes the calls, and feeds results back to the model until it finishes (or `max_turns` is reached):

```python
import aisuite as ai

def will_it_rain(location: str, time_of_day: str):
    """Check if it will rain in a location at a given time today.

    Args:
        location (str): Name of the city
        time_of_day (str): Time of the day in HH:MM format.
    """
    return "YES"

client = ai.Client()
response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=[{
        "role": "user",
        "content": "I live in San Francisco. Can you check for weather "
                   "and plan an outdoor picnic for me at 2pm?"
    }],
    tools=[will_it_rain],
    max_turns=2
)
print(response.choices[0].message.content)
```

`response.choices[0].intermediate_messages` carries the full tool-interaction history — append it to your messages to continue the conversation.

## Manual tool handling

Omit `max_turns` for full control of the loop: pass OpenAI-format JSON tool specs, and aisuite returns the model's tool-call requests for you to execute, validate, or filter yourself.

```python
tools = [{
    "type": "function",
    "function": {
        "name": "will_it_rain",
        "description": "Check if it will rain in a location at a given time today",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {"type": "string", "description": "Name of the city"},
                "time_of_day": {"type": "string", "description": "Time of the day in HH:MM format."}
            },
            "required": ["location", "time_of_day"]
        }
    }
}]

response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=messages,
    tools=tools
)
```

This mode suits custom error handling, selective execution, or integrating an existing tool pipeline. Both styles are shown in [`examples/tool_calling_abstraction.ipynb`](../examples/tool_calling_abstraction.ipynb).

## The Agents API

For longer-running, structured work, declare an `Agent` once and run it with the `Runner`. **Toolkits** are prebuilt, sandboxed tool families — files, git, and shell — ready to attach:

```python
import aisuite as ai
from aisuite import Agent, Runner

agent = Agent(
    name="repo-helper",
    model="anthropic:claude-sonnet-4-6",
    instructions="You are a careful repo assistant. Use your tools to answer from the code.",
    tools=[*ai.toolkits.files(root="."), *ai.toolkits.git(root=".")],
)

result = Runner.run(agent, "What changed in the last commit? Summarize in 3 bullets.")
print(result.final_output)
```

Pieces a production harness needs:

- **Tool policies** — gate execution with `RequireApprovalPolicy`, `AllowToolsPolicy` / `DenyAllToolPolicy`, or any callable receiving a `ToolPolicyContext`.
- **State stores** — persist runs and resume them across processes: `InMemoryStateStore`, `FileStateStore`, or `PostgresStateStore` with `thread_id`.
- **Artifacts** — store what the agent produced (`FileArtifactStore`, `InMemoryArtifactStore`).
- **Tracing** — every `RunResult` carries its steps, raw responses, and a `trace_id`; plug in trace sinks for observability.

## MCP tools

Any [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) server's tools can be handed to a model (`pip install 'aisuite[mcp]'`).

Inline config for simple cases:

```python
response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=[{"role": "user", "content": "List the files in the current directory"}],
    tools=[{
        "type": "mcp",
        "name": "filesystem",
        "command": "npx",
        "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"]
    }],
    max_turns=3
)
```

Or an explicit client you create once and reuse — with security filters and tool prefixing:

```python
from aisuite.mcp import MCPClient

mcp = MCPClient(
    command="npx",
    args=["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"]
)

response = client.chat.completions.create(
    model="openai:gpt-4o",
    messages=[{"role": "user", "content": "List the files"}],
    tools=mcp.get_callable_tools(),
    max_turns=3
)
mcp.close()
```

See [`examples/mcp_tools_example.ipynb`](../examples/mcp_tools_example.ipynb) for detailed usage.

## Going further

- Want a ready-made desktop AI coworker instead of building your own? See the [OpenWorker quickstart](openworker-quickstart.md).
- A historical OpenWorker snapshot is preserved in [`openworker-archive/`](../openworker-archive/README.md) as a reference for building a full agent harness with aisuite. Active OpenWorker development lives in the [OpenWorker repository](https://github.com/andrewyng/openworker).

---

### Chat Completions Quickstart (`docs/chat-completions-quickstart.md`)

# Chat Completions quickstart

One API across multiple LLM providers: write your code once, switch models by changing a string.

## 1. Install

```shell
pip install aisuite               # base package, no provider SDKs
pip install 'aisuite[anthropic]'  # with one provider's SDK
pip install 'aisuite[all]'        # with every provider SDK
```

## 2. Set your API keys

You need keys only for the providers you call. The [provider guides](../guides/README.md) walk through obtaining a key for each one.

Set them as environment variables (tools like [`python-dotenv`](https://pypi.org/project/python-dotenv/) or [`direnv`](https://direnv.net/) help manage them):

```shell
export OPENAI_API_KEY="your-openai-api-key"
export ANTHROPIC_API_KEY="your-anthropic-api-key"
```

Keys can also be passed programmatically to the `Client` constructor:

```python
client = ai.Client({"openai": {"api_key": "..."}})
```

## 3. Your first completions

Model names use the format `<provider>:<model-name>` — aisuite routes each call to the right provider and translates parameters and responses:

```python
import aisuite as ai
client = ai.Client()

models = ["openai:gpt-4o", "anthropic:claude-3-5-sonnet-20240620"]

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

for model in models:
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.75
    )
    print(response.choices[0].message.content)
```

Core parameters (`temperature`, `max_tokens`, `tools`, …) work provider-agnostically; aisuite maps them to each SDK's conventions.

## Local models

Run fully local via [Ollama](https://ollama.com) — no API key required:

```python
response = client.chat.completions.create(
    model="ollama:llama3.3",
    messages=[{"role": "user", "content": "Hello!"}],
)
```

## Going further

- The list of supported providers lives in [`aisuite/providers/`](../aisuite/providers/) (`<provider>_provider.py`).
- Runnable notebooks live in [`examples/`](../examples/).
- Ready to give the model tools? Continue with the [Agents quickstart](agents-quickstart.md).

---

### Openworker Quickstart (`docs/openworker-quickstart.md`)

# OpenWorker quickstart

[OpenWorker] is a desktop AI agent that can not only chat, but also do deep research and carry out tasks for you on your computer. It can read files (with permission) to gain context, read/send messages (slack, email, etc.), and create real deliverables like PDF reports, documents, spreadsheets. It also supports scheduled automations, such as providing you a daily news summary.

## 1. Install

| Platform | Download |
|---|---|
| macOS 13+ (Apple Silicon, M1 or later) | [OpenWorker-macos-arm64.dmg](https://github.com/andrewyng/aisuite/releases/latest/download/OpenWorker-macos-arm64.dmg) |
| Windows 10/11 (x64) | [OpenWorker-windows-setup.exe](https://github.com/andrewyng/aisuite/releases/latest/download/OpenWorker-windows-setup.exe) |

NOTE: On Windows, SmartScreen may warn on first run: choose **More info → Run anyway** (the build isn't Authenticode-signed yet).

## 2. Connect a model

Pick a provider and paste your API key — OpenAI, Anthropic (Claude), or Google (Gemini) — or select **Ollama** to run fully local with no key at all. Keys are stored on your machine and sent only to the provider you chose; there is no OpenWorker server.

You can connect several providers and switch models per conversation.

## 3. Give it work

Grant access to a folder (read-only or read-write) and ask in plain language. Some first tasks that show what it can do:

- *"Organize this folder — group files by type and project, and rename the screenshots based on what's in them."*
- *"Read the five vendor proposals in this folder and build me a comparison spreadsheet: pricing, terms, deadlines, red flags."*
- *"Go through the receipts in this folder and produce a monthly expense report with totals by category."*

Everything the agent produces is saved to a scratch folder by default, but you can ask it to save it to a folder of your choice. The built-in viewer previews documents, spreadsheets, images, and PDFs without leaving the app, and risky actions (shell commands, file writes outside granted folders) ask for your approval first.

## 4. Automations

Ask for recurring work and OpenWorker schedules it:

> *"Every weekday at 7am, search the web for news about my industry and write a one-page brief to my briefings folder."*

Scheduled automations run while the app is running (enable *Launch at login* in settings to keep them going).

## 5. Extend with MCP

OpenWorker speaks the Model Context Protocol. Add servers in **Manage → Integrations** using the same `mcpServers` JSON format as Claude Desktop and Cursor — stdio or HTTP, with per-tool approval controls.

## Privacy

Your API keys, your conversations, and your files stay on your machine. OpenWorker has no backend — model calls go directly from your computer to the provider you configured.

## For developers

OpenWorker is developed in the [OpenWorker repository](https://github.com/andrewyng/openworker). A historical source snapshot is preserved under [`openworker-archive/`](../openworker-archive/README.md) as a reference for building an agent harness with aisuite (see the [Agents quickstart](agents-quickstart.md)).

---

### Guides Anthropic (`guides/anthropic.md`)

# Anthropic

To use Anthropic with `aisuite` you will need to [create an account](https://console.anthropic.com/login). Once logged in, go to the [API Keys](https://console.anthropic.com/settings/keys)
and click the "Create Key" button and export that key into your environment.


```shell
export ANTHROPIC_API_KEY="your-anthropic-api-key"
```

## Create a Chat Completion

Install the `anthropic` python client

Example with pip
```shell
pip install anthropic
```

Example with poetry
```shell
poetry add anthropic
```

In your code:
```python
import aisuite as ai
client = ai.Client()


provider = "anthropic"
model_id = "claude-3-5-sonnet-20241022"

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Aws (`guides/aws.md`)

# AWS

To use AWS Bedrock with `aisuite` you will need to create an AWS account and
navigate to https://console.aws.amazon.com/bedrock/home. This route
will be redirected to your default region. In this example the region has been set to
`us-west-2`. Anywhere the region is specified can be replaced with your desired region.

Navigate to the [overview](https://us-west-2.console.aws.amazon.com/bedrock/home?region=us-west-2#/overview) page
directly or by clicking on the `Get started` link.

## Foundation Model Access

You will first need to give your AWS account access to the foundation models by
visiting the [modelaccess](https://us-west-2.console.aws.amazon.com/bedrock/home?region=us-west-2#/modelaccess)
page to enable the models you would like to use. 

After enabling the foundation models, navigate to [providers page](https://us-west-2.console.aws.amazon.com/bedrock/home?region=us-west-2#/providers) 
and select the provider of the model you would like to use. From this page select the specific model you would like to use and 
make note of the `Model ID` (currently located near the bottom) this will be used when using the chat completion example below.

Once that has been enabled set your Access Key and Secret in the env variables:

```shell
export AWS_ACCESS_KEY="your-access-key"
export AWS_SECRET_KEY="your-secret-key"
export AWS_REGION="region-name" 
```
*Note: AWS_REGION is optional, a default of `us-west-2` has been set for easy of use*

## Create a Chat Completion

Install the boto3 client using your package installer

Example with pip
```shell
pip install boto3
```

Example with poetry
```shell
poetry add boto3
```

In your code:
```python
import aisuite as ai
client = ai.Client()


provider = "aws"
model_id = "meta.llama3-1-405b-instruct-v1:0" # Model ID from above

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Azure (`guides/azure.md`)

# Azure AI

To use Azure AI with the `aisuite` library, you'll need to set up an Azure account and configure your environment for Azure AI services.

## Create an Azure Account and deploy a model from AI Studio

1. Visit [Azure Portal](https://portal.azure.com/) and sign up for an account if you don't have one.
2. Create a project and resource group.
3. Choose a model from https://ai.azure.com/explore/models and deploy it. You can choose serverless deployment option.
4. Give a deployment name. Lets say you choose to deploy Mistral-large-2407. You could leave the deployment names as "mistral-large-2407" or give a custom name.
5. You can see the deployment from project/deployment option. Note the Target URI from the Endpoint panel. It should look something like this - "https://aisuite-Mistral-large-2407.westus3.models.ai.azure.com".
6. Also note, that is provides a Chat completion URL. It should look like this - https://aisuite-Mistral-large-2407.westus3.models.ai.azure.com/v1/chat/completions


## Obtain Necessary Details & set environment variables.

After creating your deployment, you'll need to gather the following information:

1. API Key: Found in the "Keys and Endpoint" section of your Azure OpenAI resource.
2. Base URL: This can be obtained from your deployment details. It will look something like this - `https://aisuite-Mistral-large-2407.westus3.models.ai.azure.com/v1/`
3. API Version: Optional configuration and mainly introduced for Azure OpenAI services. Once specified, the `api-version` query parameters will be added in the end of the API request.


Set the following environment variables:

```shell
export AZURE_API_KEY="your-api-key"
export AZURE_BASE_URL="https://deployment-name.region-name.models.ai.azure.com/v1"
export AZURE_API_VERSION="=2024-08-01-preview"
```

## Create a Chat Completion

With your account set up and environment configured, you can send a chat completion request:

```python
import aisuite as ai

# Either set the environment variables or set the below two parameters.
# Setting the params in ai.Client() will override the values from environment vars.
client = ai.Client(
    base_url=os.environ["AZURE_OPENAI_BASE_URL"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
    api_version=os.environ["AZURE_API_VERSION"]
)

model = "azure:aisuite-Mistral-large-2407"  # Replace with your deployment name.
# The model name must match the deployment name in the base-url.

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What's the weather like today?"},
]

response = client.chat.completions.create(
    model=model,
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Cerebras (`guides/cerebras.md`)

# Cerebras AI Suite Provider Guide

## About Cerebras

At Cerebras, we've developed the world's largest and fastest AI processor, the Wafer-Scale Engine-3 (WSE-3). The Cerebras CS-3 system, powered by the WSE-3, represents a new class of AI supercomputer that sets the standard for generative AI training and inference with unparalleled performance and scalability.

With Cerebras as your inference provider, you can:
- Achieve unprecedented speed for AI inference workloads
- Build commercially with high throughput
- Effortlessly scale your AI workloads with our seamless clustering technology

Our CS-3 systems can be quickly and easily clustered to create the largest AI supercomputers in the world, making it simple to place and run the largest models. Leading corporations, research institutions, and governments are already using Cerebras solutions to develop proprietary models and train popular open-source models.

Want to experience the power of Cerebras? Check out our [website](https://cerebras.net) for more resources and explore options for accessing our technology through the Cerebras Cloud or on-premise deployments!

> [!NOTE]  
> This SDK has a mechanism that sends a few requests to `/v1/tcp_warming` upon construction to reduce the TTFT. If this behaviour is not desired, set `warm_tcp_connection=False` in the constructor.
>
> If you are repeatedly reconstructing the SDK instance it will lead to poor performance. It is recommended that you construct the SDK once and reuse the instance if possible.

## Documentation

For the most comprehensive and up-to-date Cerebras Inference docs, please visit [inference-docs.cerebras.ai](https://inference-docs.cerebras.ai).

## Usage
Get an API Key from [cloud.cerebras.ai](https://cloud.cerebras.ai/) and add it to your environment variables:

```shell
export CEREBRAS_API_KEY="your-cerebras-api-key"
```

Use the python client.

```python
import aisuite as ai
client = ai.Client()

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

response = client.chat.completions.create(
    model="cerebras:llama3.1-8b",
    messages=messages,
    temperature=0.75
)
print(response.choices[0].message.content)

```

## Requirements

Python 3.8 or higher.

---

### Guides Cohere (`guides/cohere.md`)

# Cohere

To use Cohere with `aisuite`, you’ll need an [Cohere account](https://cohere.com/). After logging in, go to the [API Keys](https://dashboard.cohere.com/api-keys) section in your account settings, agree to the terms of service, connect your card, and generate a new key. Once you have your key, add it to your environment as follows:

```shell
export CO_API_KEY="your-cohere-api-key"
```

## Create a Chat Completion

Install the `cohere` Python client:

Example with pip:
```shell
pip install cohere
```

Example with poetry:
```shell
poetry add cohere
```

In your code:
```python
import aisuite as ai

client = ai.Client()

provider = "cohere"
model_id = "command-r-plus-08-2024"

messages = [
    {"role": "user", "content": "Hi, how are you?"}
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Crusoe (`guides/crusoe.md`)

# Crusoe

To use Crusoe Managed Inference with `aisuite`, you need a [Crusoe Cloud](https://crusoecloud.com/) account. Log in to the [Crusoe Cloud Console](https://console.crusoecloud.com/) and generate an API key. Once you have a key, add it to your environment as follows:

```shell
export CRUSOE_API_KEY="your-crusoe-api-key"
```

## Create a Chat Completion

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "crusoe"
model_id = "meta-llama/Llama-3.3-70B-Instruct"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "How many times has Jurgen Klopp won the Champions League?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

See the [Crusoe Managed Inference documentation](https://docs.crusoecloud.com/managed-inference/overview) for the full list of available models.

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Deepseek (`guides/deepseek.md`)

# DeepSeek

To use DeepSeek with `aisuite`, you’ll need an [DeepSeek account](https://platform.deepseek.com). After logging in, go to the [API Keys](https://platform.deepseek.com/api_keys) section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:

```shell
export DEEPSEEK_API_KEY="your-deepseek-api-key"
```

## Create a Chat Completion

(Note: The DeepSeek uses an API format consistent with OpenAI, hence why we need to install OpenAI, there is no DeepSeek Library at least not for now)

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "deepseek"
model_id = "deepseek-chat"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What’s the weather like in San Francisco?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Edenai (`guides/edenai.md`)

# Eden AI

To use [Eden AI](https://www.edenai.co) with `aisuite`, you'll need an [Eden AI account](https://app.edenai.run). After logging in, open your account settings and generate an API key. Once you have your key, add it to your environment as follows:

```shell
export EDENAI_API_KEY="your-edenai-api-key"
```

Eden AI is an EU-hosted, OpenAI-compatible gateway that exposes 100+ models from many providers through a single API key. Models use the `provider/model` naming scheme (e.g. `anthropic/claude-sonnet-4-5`, `mistral/codestral-latest`).

## Create a Chat Completion

(Note: Eden AI uses an API format consistent with OpenAI, hence why we need to install `openai`.)

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "edenai"
model_id = "anthropic/claude-sonnet-4-5"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What's the weather like in San Francisco?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you'd like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Featherless (`guides/featherless.md`)

# Featherless.ai

To use Featherless with `aisuite`, you'll need an [featherless](https://featherless.ai/) account. After signing up, go to the [API Keys](https://featherless.ai/account/api-keys). Once you have your key, add it to your environment as follows:

```shell
export FEATHERLESS_API_KEY="your-featherless-api-key"
```

## Create a Chat Completion

Install the `openai` Python library:

Example with pip:
```shell
pip install openai
```

In your code:
```python
import aisuite as ai

client = ai.Client()

models = ["featherless:meta-llama/Meta-Llama-3.1-8B-Instruct", "featherless:meta-llama/Meta-Llama-3.1-8B-Instruct"]

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

for model in models:
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.75
    )
    print(response.choices[0].message.content)
```

Happy coding! If you'd like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Gemini (`guides/gemini.md`)

# Gemini (Gemini Developer API)

This provider talks to Google's [Gemini Developer API](https://ai.google.dev/) using a plain API key — no Google Cloud project, service account, or GCS bucket required. (For Vertex AI with GCP project auth, use the [Google](google.md) provider instead.)

## Get an API key

Visit [Google AI Studio](https://aistudio.google.com/apikey), sign in with a Google account, and click "Create API key."

## Set the API key in an environment variable

```shell
export GEMINI_API_KEY="your-gemini-api-key"
```

(`GOOGLE_API_KEY`, the SDK's own convention, works as a fallback.)

## Install the SDK and use the provider

```shell
pip install 'aisuite[gemini]'
```

```python
import aisuite as ai

client = ai.Client()

response = client.chat.completions.create(
    model="gemini:gemini-2.5-flash",
    messages=[{"role": "user", "content": "Tell me a joke."}],
)
print(response.choices[0].message.content)
```

Tool calling, streaming (`stream=True`), and image inputs are supported. Images must be base64 data URLs (`data:image/png;base64,...`) in OpenAI-style `image_url` content parts — the Gemini API does not fetch plain http(s) URLs.

---

### Guides Google (`guides/google.md`)

# Google (Vertex) AI

To use Google (Vertex) AI with the `aisuite` library, you'll first need to create a Google Cloud account and set up your environment to work with Google Cloud.

## Create a Google Cloud Account and Project

Google Cloud provides in-depth [documentation](https://cloud.google.com/vertex-ai/docs/start/cloud-environment) on getting started with their platform, but here are the basic steps:

### Create your account.

Visit [Google Cloud](https://cloud.google.com/free) and follow the instructions for registering a new account. If you already have an account with Google Cloud, sign in and skip to the next step.

### Create a new project and enable billing.

Once you have an account, you can create a new project. Visit the [project selector page](https://console.cloud.google.com/projectselector2/home/dashboard) and click the "New Project" button. Give your project a name and click "Create Project." Your project will be created and you will be redirected to the project dashboard.

Now that you have a project, you'll need to enable billing. Visit the [how-to page](https://cloud.google.com/billing/docs/how-to/verify-billing-enabled#confirm_billing_is_enabled_on_a_project) for billing enablement instructions.

### Set your project ID in an environment variable.

Set the `GOOGLE_PROJECT_ID` environment variable to the ID of your project. You can find the Project ID by visiting the project dashboard in the "Project Info" section toward the top of the page.

### Set your preferred region in an environment variable.

Set the `GOOGLE_REGION` environment variable. You can find the region by going to Project Dashboard under VertexAI side navigation menu, and then scrolling to the bottom of the page.

## Create a Service Account For API Access

Because `aisuite` needs to authenticate with Google Cloud to access the Vertex AI API, you'll need to create a service account and set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of a JSON file containing the service account's credentials, which you can download from the Google Cloud Console.

This is documented [here](https://cloud.google.com/docs/authentication/provide-credentials-adc#how-to), and the basic steps are as follows:

1. Visit the [service accounts page](https://console.cloud.google.com/iam-admin/serviceaccounts) in the Google Cloud Console.
2. Click the "+ Create Service Account" button toward the top of the page.
3. Follow the steps for naming your service account and granting access to the project.
4. Click "Done" to create the service account.
5. Now, click the "Keys" tab towards the top of the page.
6. Click the "Add Key" menu, then select "Create New Key."
6. Choose "JSON" as the key type, and click "Create."
7. Move this file to a location on your file system like your home directory.
8. Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of the JSON file.

## Double check your environment is configured correctly.

At this point, you should have three environment variables set to ensure your environment is set up correctly:

- `GOOGLE_PROJECT_ID`
- `GOOGLE_REGION`
- `GOOGLE_APPLICATION_CREDENTIALS`

Once these are set, you are ready to write some code and send a chat completion request.

## Create a chat completion.

With your account and service account set up, you can send a chat completion request.

Export the environment variables:

```shell
export GOOGLE_PROJECT_ID="your-project-id"
export GOOGLE_REGION="your-region"
export GOOGLE_APPLICATION_CREDENTIALS="path/to/your/service-account-file.json"
```

Install the Vertex AI SDK:

```shell
pip install vertexai
```

In your code:

```python
import aisuite as ai
client = ai.Client()

model="google:gemini-1.5-pro-001"

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

response = client.chat.completions.create(
    model=model,
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Groq (`guides/groq.md`)

# Groq

To use Groq with `aisuite`, you’ll need a free [Groq account](https://console.groq.com/). After logging in, go to the [API Keys](https://console.groq.com/keys) section in your account settings and generate a new Groq API key. Once you have your key, add it to your environment as follows:

```shell
export GROQ_API_KEY="your-groq-api-key"
```

## Create a Python Chat Completion

1. First, install the `groq` Python client library:

```shell
pip install groq
```

2. Now you can simply create your first chat completion with the following example code or customize by swapoping out the `model_id` with any of the other available [models powered by Groq](https://console.groq.com/docs/models) and `messages` array with whatever you'd like:
```python
import aisuite as ai
client = ai.Client()

provider = "groq"
model_id = "llama-3.2-3b-preview"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What’s the weather like in San Francisco?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```


Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Huggingface (`guides/huggingface.md`)

# Hugging Face AI

To use Hugging Face with the `aisuite` library, you'll need to set up a Hugging Face account, obtain the necessary API credentials, and configure your environment for Hugging Face's API.

## Create a Hugging Face Account and Deploy a Model

1. Visit [Hugging Face](https://huggingface.co/) and sign up for an account if you don't already have one.
2. Explore conversational models on the [Hugging Face Model Hub](https://huggingface.co/models?inference=warm&other=conversational&sort=trending) and select a model you want to use. Popular models include conversational AI models like `gpt2`, `gpt3`, and `mistral`.
3. Deploy or host your chosen model if needed; Hugging Face provides various hosting options, including free, individual, and organizational hosting. Using Serverless Inference API is a fast way to get started.
5. Once the model is deployed (or if using a public model directly), note the model's unique identifier (e.g., `mistralai/Mistral-7B-Instruct-v0.3`), which you'll use for making requests.

## Obtain Necessary Details & Set Environment Variables

After setting up your model, you'll need to gather the following information:

- **API Token**: You can generate an API token in your [Hugging Face account settings](https://huggingface.co/settings/tokens).

Set the following environment variables to make authentication and requests easy:

```shell
export HF_TOKEN="your-api-token"
```

## Create a Chat Completion

With your account set up and environment variables configured, you can send a chat completion request as follows:

```python
import os
import aisuite as ai

# Either set the environment variables or define the parameters below.
# Setting the parameters in ai.Client() will override the environment variable values.
client = ai.Client()

model = "huggingface:your-model-name"  # Replace with your model's identifier.

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What's the weather like today?"},
]

response = client.chat.completions.create(
    model=model,
    messages=messages,
)

print(response.choices[0].message.content)
```

### Notes

- Ensure that the `model` variable matches the identifier of your model as seen in the Hugging Face Model Hub.
- If you encounter any rate limits or API access restrictions, you may have to upgrade your Hugging Face plan to enable higher usage limits.
"""

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Lmstudio (`guides/lmstudio.md`)

# LM Studio

LM Studio allows users to locally host open-source models available in [their model catalog](https://lmstudio.ai/models). 
It also provides a web portal with a ChatGPT-like interface.
Once an LM Studio instance is locally running in your setup (default `http://localhost:1234`), you can use the `aisuite` API for chat completions as shown below.
No API Key is needed for these locally hosted models.

## Create a Chat Completion

Sample code:
```python
import aisuite as ai

def main():
    # Set the API URL to remote NGA2 server
    client = ai.Client(
        provider_configs={
            "lmstudio": {
                "api_url": "http://localhost:1234",
                "timeout": 300,
            }
        }
    )
    messages = [
        {
            "role": "system", 
            "content": "Be verbose"
        },
        {
            "role": "user", 
            "content": "Tell me something about University of Michigan's CSE department."
        },
    ]

    lmstudio_llama = "lmstudio:llama-3.2-3b-instruct"

    response = client.chat.completions.create(
        model=lmstudio_llama, 
        messages=messages, 
        temperature=0.75,
    )
    print(response.choices[0].message.content)


if __name__ == "__main__":
    main()
```

You can expect a response like the following:

```
/* Detailed source-code truncated for AI context efficiency. */
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Mistral (`guides/mistral.md`)

# Mistral

To use Mistral with `aisuite`, you’ll need a [Mistral account](https://console.mistral.ai/). 

After logging in, go to [Workspace billing](https://console.mistral.ai/billing) and choose a plan
- **Experiment** *(Free, 1 request per second); or*
- **Scale** *(Pay per use).*

Visit the [API Keys](https://console.mistral.ai/api-keys/) section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:

```shell
export MISTRAL="your-mistralai-api-key"
```
## Create a Chat Completion

Install the `mistralai` Python client:

Example with pip:
```shell
pip install mistralai
```

Example with poetry:
```shell
poetry add mistralai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "mistral"
model_id = "mistral-large-latest"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What’s the weather like in Montréal?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Nebius (`guides/nebius.md`)

# Nebius AI Studio

To use Nebius AI Studio with `aisuite`, you need an AI Studio account. Go to [AI Studio](https://studio.nebius.ai/) and press "Log in to AI Studio" in the right top corner. After logging in, go to the [API Keys](https://studio.nebius.ai/settings/api-keys) section and generate a new key. Once you have a key, add it to your environment as follows:

```shell
export NEBIUS_API_KEY="your-nebius-api-key"
```

## Create a Chat Completion

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "nebius"
model_id = "meta-llama/Llama-3.3-70B-Instruct"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "How many times has Jurgen Klopp won the Champions League?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Ollama (`guides/ollama.md`)

# Ollama

Ollama allows users to locally host open-source models available in [their library](https://ollama.com/library). 
Once an Ollama instance is locally running in your setup (default `http://localhost:11434`), you can use the `aisuite` API for chat completions as shown below.
No API Key is needed for these locally hosted models.

## Create a Chat Completion

Sample code:
```python
import aisuite as ai

def main():
    client = ai.Client(
        provider_configs={
            "ollama": {
                "base_url": "http://10.168.0.177:11434",
                "timeout": 300,
            }
        }
    )
    messages = [
        {
            "role": "system", 
            "content": "Be verbose"
        },
        {
            "role": "user", 
            "content": "Tell me something about University of Michigan's CSE department."
        },
    ]

    ollama_llama3 = "ollama:llama3:latest"
    ollama_gemma = "ollama:gemma:latest"
    ollama_deepseek_32B = "ollama:deepseek-r1:32b"
    ollama_deepseek_70B = "ollama:deepseek-r1:70b"

    response = client.chat.completions.create(
        model=ollama_gemma, 
        messages=messages, 
        temperature=0.75,
    )
    print(response.choices[0].message.content)


if __name__ == "__main__":
    main()
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Openai (`guides/openai.md`)

# OpenAI

To use OpenAI with `aisuite`, you’ll need an [OpenAI account](https://platform.openai.com/). After logging in, go to the [API Keys](https://platform.openai.com/account/api-keys) section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:

```shell
export OPENAI_API_KEY="your-openai-api-key"
```

## Create a Chat Completion

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "openai"
model_id = "gpt-4-turbo"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What’s the weather like in San Francisco?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Sambanova (`guides/sambanova.md`)

# Sambanova

To use Sambanova with `aisuite`, you’ll need a [Sambanova Cloud](https://cloud.sambanova.ai/) account. After logging in, go to the [API](https://cloud.sambanova.ai/apis) section and generate a new key. Once you have your key, add it to your environment as follows:

```shell
export SAMBANOVA_API_KEY="your-sambanova-api-key"
```

## Create a Chat Completion

Install the `openai` Python client:

Example with pip:
```shell
pip install openai
```

Example with poetry:
```shell
poetry add openai
```

In your code:
```python
import aisuite as ai
client = ai.Client()

provider = "sambanova"
model_id = "Meta-Llama-3.1-405B-Instruct"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What’s the weather like in San Francisco?"},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Guides Tongyi (`guides/tongyi.md`)

# Tongyi

To use the Alibaba Tongyi model, you need to log in to [Alibaba Cloud Bailian](https://bailian.console.aliyun.com) to obtain an API key. Once you have the API key, set it as an environment variable as follows:

```shell
export TONGYI_API_KEY="your-api-key"
```

## Create a Chat Completion

### Example Using pip:

```shell
pip install aisuite[all]
```

### Example with poetry:

```shell
poetry add aisuite
```

### In Your Code:

```python
import aisuite as ai

# Initialize the client
client = ai.Client()

# Set provider and model ID
provider = "tongyi"
model_id = "qwen-plus"

# Prepare messages
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Hello!"},
]

# Make a chat completion request
response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

# Output the response
print(response.choices[0].message.content)
```

Happy coding! If you would like to contribute, please read our [Contributing Guide](../CONTRIBUTING.md).

---

### Guides Watsonx (`guides/watsonx.md`)

# Watsonx with `aisuite`

A a step-by-step guide to set up Watsonx with the `aisuite` library, enabling you to use IBM Watsonx's powerful AI models for various tasks.

## Setup Instructions

### Step 1: Create a Watsonx Account

1. Visit [IBM Watsonx](https://www.ibm.com/watsonx).
2. Sign up for a new account or log in with your existing IBM credentials.
3. Once logged in, navigate to the **Watsonx Dashboard** (<https://dataplatform.cloud.ibm.com>)

---

### Step 2: Obtain API Credentials

1. **Generate an API Key**:
   - Go to IAM > API keys and create a new API key (<https://cloud.ibm.com/iam/overview>)
   - Copy the API key. This is your `WATSONX_API_KEY`.

2. **Locate the Service URL**:
   - Your service URL is based on the region where your service is hosted.
   - Pick one from the list here <https://cloud.ibm.com/apidocs/watsonx-ai#endpoint-url>
   - Copy the service URL. This is your `WATSONX_SERVICE_URL`.

3. **Get the Project ID**:
   - Go to the **Watsonx Dashboard** (<https://dataplatform.cloud.ibm.com>)
   - Under the **Projects** section, If you don't have a sandbox project, create a new project.
   - Navigate to the **Manage** tab and find the **Project ID**.
   - Copy the **Project ID**. This will serve as your `WATSONX_PROJECT_ID`.

---

### Step 3: Set Environment Variables

To simplify authentication, set the following environment variables:

Run the following commands in your terminal:

```bash
export WATSONX_API_KEY="your-watsonx-api-key"
export WATSONX_SERVICE_URL="your-watsonx-service-url"
export WATSONX_PROJECT_ID="your-watsonx-project-id"
```


## Create a Chat Completion

Install the `ibm-watsonx-ai` Python client:

Example with pip:

```shell
pip install ibm-watsonx-ai
```

Example with poetry:

```shell
poetry add ibm-watsonx-ai
```

In your code:

```python
import aisuite as ai
client = ai.Client()

provider = "watsonx"
model_id = "meta-llama/llama-3-70b-instruct"

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Tell me a joke."},
]

response = client.chat.completions.create(
    model=f"{provider}:{model_id}",
    messages=messages,
)

print(response.choices[0].message.content)
```

---

### Guides Xai (`guides/xai.md`)

# xAI

To use xAI with `aisuite`, you’ll need an [API key](https://console.x.ai/). Generate a new key and once you have your key, add it to your environment as follows:

```shell
export XAI_API_KEY="your-xai-api-key"
```

## Create a Chat Completion

Sample code:
```python
import aisuite as ai
client = ai.Client()

models = ["xai:grok-beta"]

messages = [
    {"role": "system", "content": "Respond in Pirate English."},
    {"role": "user", "content": "Tell me a joke."},
]

for model in models:
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0.75
    )
    print(response.choices[0].message.content)

```

Happy coding! If you’d like to contribute, please read our [Contributing Guide](CONTRIBUTING.md).

---

### Openworker Archive Docs CONNECTORS BATCH2 SPEC (`openworker-archive/docs/CONNECTORS-BATCH2-SPEC.md`)

# Connectors Batch 2 — Notion, Attio (managed OAuth) + PostHog, Mixpanel, Amplitude, Apollo, Hunter (manual) — with a generic multi-account layer

Status: agreed 2026-07-10 (Rohit + agent). Supersedes nothing; extends M3.6.

## Goals

1. Seven new connectors: **Notion + Attio** with one-click managed OAuth via the
   ocw-connect broker (manual token paste also available, as always), and
   **PostHog, Mixpanel, Amplitude, Apollo, Hunter** as manual API-key connectors.
2. **Multi-account from day one, for all seven.** Users routinely hold multiple
   accounts of the same SaaS (two Notion workspaces, several PostHog projects).
   We already do this bespoke for Slack (workspaces), Gmail/GCal (accounts), and
   HubSpot (portals) — this batch introduces the GENERIC account layer so we
   never write a bespoke module again.
3. Every tool result and approval card names the account it touched.

## Non-goals

- Migrating gmail/gcal/hubspot/slack onto the generic layer (later refactor;
  their shipped behavior must not churn in the same batch that adds 7 connectors).
- Write tools beyond the minimal useful set (this batch is read-heavy).
- Webhooks/relay inbound for any of the seven (outbound/read only).
- Per-account privacy filters (Gmail-style "never show agents") — later if asked.

## 1. Generic account layer — `coworker/connectors/accounts.py`

Same shape as `gcal_accounts.py`, parameterized by connector name:

- Profiles at `<connector>:account:<account_id>`; `<connector>:default` is a
  POINTER ONLY: `{"default_account": <id>}` (plus connector-wide settings like
  `enabled`). Never tokens.
- `list_accounts(secrets, connector) -> [(id, profile)]`
- `default_account(secrets, connector) -> id` (pointer, else sole account)
- `resolve(secrets, connector, account="") -> (profile_key, profile)` — explicit
  account, else default, else the sole account; `{}` when nothing connected.
- `add_account(secrets, connector, account_id, fields)` — used by both manual
  connect and the managed OAuth callback; sets the default pointer if first.
- `set_default`, `disconnect_account` (last disconnect deletes the pointer).
- **Legacy migration**: a token-bearing `<connector>:default` (from any older
  build) is treated as one account and lazily rewritten on first touch —
  mirrors the shipped gmail/gcal migration; no user action.

**Account id per connector** (stable, human-meaningful):

| connector | account_id | shown as |
|---|---|---|
| notion | workspace_id (from OAuth token response) | workspace_name |
| attio | workspace_id (from `GET /v2/self`) | workspace name |
| posthog | project_id | `host · project` |
| mixpanel | project_id | project_id |
| amplitude | api_key last 6 | `key …abc123` |
| apollo | validator identity (account email) | email |
| hunter | validator identity (account email) | email |

Descriptor gains `account_field: str = ""` — the creds field (or `"@identity"`
sentinel = use ValidationResult.identity) that names the account. Non-empty
`account_field` marks the connector as account-patterned everywhere
(connect path, connector_list, server routes, GUI).

## 2. Desktop plumbing

- **Connect (manual)**: for account-patterned connectors, `connect_connector`
  validates, derives the account id, and writes `<name>:account:<id>` via
  `add_account` instead of overwriting `<name>:default`. Re-connecting the same
  id updates it; a different id adds a second account. Managed OAuth callback
  does the same with broker-returned identity.
- **connector_list**: generic branch for account-patterned connectors, same
  shape as gmail/gcal: `accounts: [{account, default, managed}]`,
  `connected = len(accounts) > 0`, `account` = default account (back-compat).
- **Routes** (generic, allowlisted to account-patterned connectors):
  - `POST /v1/connectors/{name}/accounts/{id}/disconnect` (+ best-effort
    cloud_disconnect for managed profiles)
  - `POST /v1/connectors/{name}/accounts/{id}/default`
  (gcal keeps its existing specific routes; no alias churn.)
- **Tools**: every tool gets `account: str = ""`; shared helper
  `_account_profile(secrets, connector, account, *fields)` (resolve →
  `ensure_fresh_connector_token(profile_key=…)` for managed → field check);
  every result passes through `_acct_result(account_id, result)` so approvals
  and transcripts name the account.

## 3. The seven connectors

### Notion (managed + manual)
- OAuth: authorize `https://api.notion.com/v1/oauth/authorize` (`owner=user`),
  token exchange with **HTTP Basic** (client_id:secret). Tokens are long-lived
  (no refresh flow). Token response carries `workspace_id`/`workspace_name` —
  the broker forwards them; desktop stores them on the account profile.
- Manual path: paste an internal-integration token; account id = workspace id
  from `GET /v1/users/me` (bot owner workspace).
- Tools: `notion_search(query, account)` (POST /v1/search),
  `notion_read_page(page_id, account)` (page + block children, text-flattened),
  `notion_query_database(database_id, filter_json, account)`,
  `notion_create_page(parent_id, title, markdown_body, account)` [write].
- Validator: `GET /v1/users/me`.

### Attio (managed + manual)
- OAuth: authorize `https://app.attio.com/authorize`, token
  `https://app.attio.com/oauth/token` (standard code exchange). Long-lived
  access token. Identity: `GET /v2/self` → workspace id + name.
- Manual path: workspace API key (same header).
- Tools: `attio_list_objects(account)` (GET /v2/objects),
  `attio_search_records(object, query, account)` (POST
  /v2/objects/{object}/records/query), `attio_get_record(object, record_id,
  account)`, `attio_create_note(parent_object, parent_record_id, title,
  content, account)` [write].
- Validator: `GET /v2/self`.

### PostHog (manual)
- Fields: `base_url` (default `https://us.posthog.com`), `api_key` (personal),
  `project_id`. Bearer auth.
- Tools: `posthog_query(hogql, account)` (POST /api/projects/{pid}/query,
  HogQLQuery kind), `posthog_list_insights(query, max_results, account)`.
- Validator: `GET /api/users/@me/` → email.

### Mixpanel (manual)
- Fields: service account `username` + `secret`, `project_id`. HTTP Basic.
- Tools: `mixpanel_segmentation(event, from_date, to_date, unit, where,
  account)` (GET mixpanel.com/api/query/segmentation),
  `mixpanel_top_events(max_results, account)` (GET /api/query/events/top).
- Validator: `GET https://mixpanel.com/api/app/me`.

### Amplitude (manual)
- Fields: `api_key`, `secret_key`. HTTP Basic. Dashboard REST API.
- Tools: `amplitude_active_users(start, end, metric, account)` (GET
  amplitude.com/api/2/users), `amplitude_event_totals(event_type, start, end,
  account)` (GET /api/2/events/segmentation).
- Validator: `GET /api/2/annotations`.

### Apollo (manual)
- Field: `api_key` (X-Api-Key header).
- Tools: `apollo_enrich_person(email, name, company_domain, account)` (POST
  /api/v1/people/match), `apollo_enrich_company(domain, account)` (GET
  /api/v1/organizations/enrich), `apollo_search_people(query, max_results,
  account)` (POST /api/v1/mixed_people/search).
- Validator: `GET /api/v1/auth/health`.

### Hunter (manual)
- Field: `api_key` (query param).
- Tools: `hunter_domain_search(domain, max_results, account)`,
  `hunter_find_email(domain, first_name, last_name, account)`,
  `hunter_verify_email(email, account)`.
- Validator: `GET /v2/account` → email.

All tools approval-gated (house default); `caps=[<connector>, "read"|"write"]`.
Writes in batch: `notion_create_page`, `attio_create_note` only.

## 4. Broker (ocw-connect)

- Two `Provider` entries: `notion` (Basic-auth token exchange, no refresh,
  forward `workspace_id`/`workspace_name`/`bot_id` in the callback result) and
  `attio` (standard exchange). No scope sets (both fix scopes at app level).
- Redirect URIs: `https://api.opencoworker.app/v1/oauth/{notion,attio}/callback`.
- Stateless as ever: tokens go to the desktop, nothing at rest.
- **Prereqs (Rohit)**: register the Notion public integration + Attio app,
  add client ids/secrets to the `ocw-connect/oauth-providers` secret
  (key names only in any transcript), approve terraform apply.

## 5. GUI

- **AccountsDetail** (generic detail page, registered for all seven): accounts
  group with Default badge / set-default / × disconnect-account, "＋ Add
  account" (managed → `connectManaged`; manual → the connector's field form),
  Tools disclosure, footer Disconnect-all. Modeled on CalendarDetail; replaces
  GenericDetail for account-patterned connectors.
- ConnectorsList row status shows `N accounts` when >1.
- Add-connection modal: Notion/Attio get One click | Manual pills (≥2 modes);
  the five manual ones keep the plain field form.
- Visual review in the dev app before PR (house rule).

## 6. Tests

- `tests/test_accounts.py`: generic layer — add/list/resolve/default/disconnect
  /legacy-migration, per connector-name isolation.
- `test_connectors.py`: routing tests for all new tools (fake `_request`),
  not-connected errors, account param → profile resolution, `_acct_result`
  stamping, write tools approval-pinned.
- `test_setup.py`-style: connector_list accounts branch; connect twice with
  different ids → two accounts, default stable.
- Server route tests: generic disconnect/default (allowlist rejects
  non-account connectors).
- Broker: provider config tests (authorize URL shape, Basic-auth exchange for
  notion), callback forwarding of workspace fields.
- e2e: accounts page spec (fixtures with two Notion accounts), add-account
  modal, default switch; connectors-list count row.
- Live drills (with ledger entries for anything caught): two Notion
  workspaces one-click; Attio workspace; each manual key validated; account
  disambiguation in approvals; dual-account tool calls.

## 7. Build order (each step lands green + committed)

- **Step 0** — accounts.py + descriptor `account_field` + connect-path +
  connector_list branch + generic routes + tests.
- **Step 1** — the five manual connectors on the pattern (descriptors,
  validators, tools, tool_defs) + tests.               [task #27]
- **Step 2** — broker notion/attio providers + tests; deploy after prereqs +
  explicit approval.                                    [task #28]
- **Step 3** — desktop notion/attio (descriptors managed=True, callback
  branches, tools) + tests.                             [task #29]
- **Step 4** — GUI AccountsDetail + modal pills + e2e; visual review.
- **Step 5** — live drills, ledger, then DMG rebuild (full build_dmg.sh,
  signed).                                              [task #30]

## Open questions (defaulted, flag to change)

- Attio/Notion write tools kept minimal (create page / note). More on ask.
- PostHog EU cloud → covered by `base_url`; self-hosted too.
- Amplitude EU residency zone (analytics.eu.amplitude.com) — not in v1.

---

### Openworker Archive Docs FAKE SLACK SPEC (`openworker-archive/docs/FAKE-SLACK-SPEC.md`)

# FakeSlack — Standalone Slack test double (spec)

**Status:** separate deliverable (assign to a sub-agent). **Build this first** — the messaging
integration tests in [`UI-REFRESH-VERIFICATION.md`](UI-REFRESH-VERIFICATION.md) depend on it.

**Goal:** a controllable, local fake of the slices of Slack we use (Web API + Socket Mode), so the
real `SlackAdapter`/`Gateway` can run end-to-end **without the network, tokens, or the Slack app
console**. Replaces the manual `COWORKER_DEBUG_INJECT` flow with deterministic, scriptable tests.

**Non-goals:** not a faithful Slack (no real auth, no rate limits, no full schema). Implement only what
the adapter calls + what tests need to drive.

---

## How the app points at it (the one required adapter change)

The Slack adapter (`coworker/connectors/adapters.py`) builds `slack_bolt.async_app.AsyncApp` +
`AsyncSocketModeHandler`, which talk to `https://slack.com/api/`. Add a **base-URL override** so it can
target the fake:
- Accept an override (env `SLACK_API_URL`, default `https://slack.com/api/`) and pass it to the
  `AsyncWebClient(base_url=…)` the app uses. Slack's `apps.connections.open` is itself a Web API call,
  so the **socket URL the fake returns** flows from the same base — one override redirects both Web API
  and the Socket Mode connection.
- This is the only production-code change FakeSlack requires; keep it minimal and documented.

Tests/standalone set `SLACK_API_URL=http://127.0.0.1:<port>/api/` and connect the adapter with any
fake `xoxb-`/`xapp-` strings.

---

## Surfaces

### 1. Web API (HTTP) — implement exactly what the adapter calls
Base path `/<port>/api/<method>`; all return `{"ok": true, …}` (or `{"ok": false, "error": …}` when a
test asks for a failure). Methods:

| Method | Request | Response | Notes |
|---|---|---|---|
| `auth.test` | bot token | `{ok, user_id, team, team_id, url}` | identifies the bot; `user_id` = the fake bot's id (loop-guard) |
| `apps.connections.open` | app token | `{ok, url: "ws://127.0.0.1:<port>/socket"}` | hands back the fake's WS URL |
| `users.info` | `user` | `{ok, user:{id, name, real_name, profile:{display_name, real_name}}}` | from the registered user table; unknown id → `{ok:false,error:"user_not_found"}` |
| `conversations.info` | `channel` | `{ok, channel:{id, name, is_im}}` | from the channel table |
| `chat.postMessage` | `channel, text, blocks?, thread_ts?` | `{ok, ts, channel}` | **record** it (test-inspectable); allocate a monotonic `ts` |
| `chat.update` | `channel, ts, text, blocks?` | `{ok, ts, channel}` | **record** it |

Anything else the adapter happens to call → `{ok:true}` no-op (log it so gaps surface).

### 2. Socket Mode (WebSocket) — `/socket`
Mimic Slack's envelope protocol the `AsyncSocketModeHandler` expects:
- On connect, send `{"type":"hello"}`.
- **Inbound events** (a user posts): send an envelope
  `{"envelope_id": <uuid>, "type":"events_api", "payload":{"token":"…","type":"event_callback",
  "event":{"type":"message","channel":<id>,"channel_type":"channel"|"im","user":<id>,"text":<str>,
  "ts":<ts>,"thread_ts"?:<ts>}}}`. Client replies `{"envelope_id":…}` (ack) — the fake may ignore acks
  or record them.
- **Interactions** (button click): send
  `{"envelope_id":<uuid>,"type":"interactive","payload":{"type":"block_actions","user":{"id":<id>,
  "username":<name>},"channel":{"id":<id>},"message":{"ts":<ts>},"actions":[{"action_id":<str>,
  "value":<str>}]}}`. Client acks.
- Match the exact envelope shape `slack_bolt` decodes (verify against the installed `slack_bolt`/
  `slack_sdk` version in the aisuite venv — this is the fiddly part; write a focused test that the
  real handler dispatches a fake-sent envelope).

### 3. Control / admin API (the test-facing surface) — HTTP under `/control/*`
This is how tests + the standalone runner drive scenarios:

| Endpoint | Purpose |
|---|---|
| `POST /control/users` `{id, name, real_name}` | register a user (so `users.info` resolves a name) |
| `POST /control/channels` `{id, name, is_im}` | register a channel/DM |
| `POST /control/inbound` `{channel, user, text, thread_ts?, channel_type}` | push a message over the socket |
| `POST /control/interaction` `{channel, user, username, message_ts, action_id, value}` | push a button click |
| `GET /control/outbound` | the recorded `chat.postMessage`/`chat.update` calls (for assertions) |
| `POST /control/reset` | clear users/channels/recorded calls + drop sockets |
| `GET /control/health` | readiness |

Programmatic API mirrors these (a `FakeSlack` Python object with `add_user/add_channel/
inbound/interaction/outbound/reset`) so pytest can drive without HTTP when embedded.

---

## Packaging & modes
- Location: `platform/coworker/testing/fake_slack/` (a package), with `server.py` (aiohttp app —
  `aiohttp` is already a dep) and `__init__.py` exporting `FakeSlack`.
- **Embedded (pytest):** a fixture `fake_slack` that starts the server on an ephemeral port, sets
  `SLACK_API_URL`, yields the control object, tears down. Used by the integration/e2e tests.
- **Standalone (manual GUI testing):** `python -m coworker.testing.fake_slack --port 8910` runs it;
  print the `SLACK_API_URL` to export + a tiny REPL or curl examples for the control API, so a human
  can drive the live dev app against it (replaces real Slack for visual review).
- No new third-party dependencies (aiohttp only).

---

## Acceptance (the fake's own tests)
1. `SlackAdapter` with `SLACK_API_URL` → fake connects: `connect()` returns True, `auth.test` resolved
   the bot id, Socket Mode `hello` received.
2. `POST /control/inbound` → the gateway's inbound handler fires with a `MessageEvent` whose
   `user_name`/`chat_name` are **resolved** from the registered tables (exercises `users.info` +
   `conversations.info` caching).
3. Agent/test calls `send`/`send_interactive` → recorded under `GET /control/outbound` with the right
   channel/text/blocks.
4. `POST /control/interaction` → the adapter's `ocw_*` action handler fires → reaches
   `Gateway._on_interaction` (resolves the inbox item).
5. `POST /control/reset` returns the fake to a clean slate between tests.
6. A focused test proves the **real** `slack_bolt` handler dispatches a fake-sent events_api **and**
   interactive envelope (guards against envelope-shape drift).

When done, wire `test_ui_refresh_e2e.py` (VERIFICATION "Cross-cutting acceptance") on top of it.

---

### Openworker Archive Docs GDOCS SHEETS SPEC (`openworker-archive/docs/GDOCS-SHEETS-SPEC.md`)

# Google Docs & Sheets connector — by-URL reads, tiered Sheets writes

Status: agreed 2026-07-10 (Rohit + agent). Motivated by the competitor
use-case analysis (ocw-context `docs/competitor-use-cases.md`): Google
docs/spreadsheets appear in 6 of 8 flagship workflows, always as "THIS doc /
THIS spreadsheet" — a pasted link, never a Drive-wide search.

## Decisions (locked)

- **One combined connector** `google_docs` (title: "Google Docs & Sheets").
  Both scope sets are Google **sensitive** tier, so combining costs nothing on
  the verification ladder — the gmail/google_calendar split existed to keep
  Calendar out of Gmail's *restricted*/CASA bucket, which doesn't apply here.
  One consent per Google account instead of two.
- **Consent tiers, HubSpot-style** (chosen at connect time, named tiers only):
  - `read` (default): `documents.readonly` + `spreadsheets.readonly`
  - `write`: `documents.readonly` + `spreadsheets` (full) — enables
    create-a-tracker, update-cells, append-rows. Docs stay read-only in both.
- **No Drive scope, no Drive search.** Documents are addressed by URL or id.
  Drive-wide search (`drive.readonly`, RESTRICTED → CASA) is wave 2, bundled
  with Gmail's CASA whenever that happens.
- **Multi-account from day one** via the generic accounts layer
  (CONNECTORS-BATCH2-SPEC §1): `account_field="@identity"` — the account key
  is the Google email (broker sends it as `account` from the id_token).

## Non-goals (wave 1)

- Docs WRITE (create/edit Google Docs) — briefs land as local artifacts or
  Notion pages; revisit on demand (`documents` scope is also sensitive, so
  it's an easy later add).
- Drive listing/search, Slides, Forms.
- Per-document privacy filters (nothing to filter without search).

## Broker (ocw-connect)

- `google` provider `connector_scopes` gains
  `"google_docs": ["documents.readonly", "spreadsheets.readonly"]`
  (full URLs). The existing `google_drive` stub stays for wave 2.
- **Model extension**: access tiers must be PER CONNECTOR on multi-connector
  providers — today `access_scopes` is provider-level (fine for HubSpot, a
  single-connector provider; wrong for google, where a `write` tier must not
  leak into gmail/calendar consents). Add
  `connector_access_scopes: dict[connector, dict[tier, list[scope]]]`;
  `start` resolves connector tier first, then provider-level (back-compat).
  For `google_docs`: `write` REPLACES `spreadsheets.readonly` with
  `spreadsheets` (not additive — avoid redundant scope pairs).
- Callback: nothing new — google identity extraction (email from id_token)
  already covers it; `account_id` already rides the loopback POST.
- **Prereq (Rohit)**: add the two sensitive scopes to the OAuth consent
  screen's scope list (same client, Testing mode: test users + 7-day refresh
  as usual). Publishing later = standard verification, no CASA.

## Desktop

- Descriptor `google_docs`: `managed=True`, `account_field="@identity"`,
  brand color `#4285f4`; manual field = `access_token` paste (same shape as
  gmail's — rarely used but keeps the local-only path alive). Validator:
  `GET https://www.googleapis.com/oauth2/v2/userinfo` → email.
- `PROVIDER_FOR_CONNECTOR["google_docs"] = "google"`. Managed connect passes
  `access` tier through `begin_managed_connect` (HubSpot precedent); the
  granted scope string is stored on the profile → connector_list exposes
  `access: "read" | "write"` per account (badge in the GUI, like HubSpot).
- **URL/id parsing** (shared helper): accepts full URLs
  (`docs.google.com/document/d/<id>/…`, `docs.google.com/spreadsheets/d/<id>/…`)
  or bare ids; the doc-vs-sheet kind comes from the URL path when present and
  is validated against the tool being called.

### Tools (all with `account` param + `_acct_result` stamping)

Read tier:
- `gdoc_read(url_or_id, account)` — Docs API `documents.get`, body flattened
  to readable text: headings prefixed (#/##), list items bulleted, table rows
  as `cell | cell | cell` lines. Returns `{title, text, truncated}` (cap
  ~50k chars).
- `gsheet_read(url_or_id, range="", account)` — no range: return
  `{title, sheets:[{name, rows, cols}]}` + the FIRST sheet's used range
  values; with range (`Sheet1!A1:D50` or just `Sheet1`): `values.get`.
  Values come back as a compact rows array; cap ~2k cells per call
  (ask for a narrower range beyond that).

Write tier (approval-gated, only usable when the account's grant includes
`spreadsheets`; a read-tier account gets a clear "connected read-only —
reconnect with write access" error):
- `gsheet_update_cells(url_or_id, range, rows_json, account)` —
  `values.update` (RAW input option; rows_json = JSON array of arrays).
- `gsheet_append_rows(url_or_id, range, rows_json, account)` — `values.append`.
- `gsheet_create(title, rows_json="", account)` — `spreadsheets.create`
  (+ optional initial values); returns the new sheet's URL.

tool_defs: 2 read + 3 write entries; write tools carry the tier note in
their descriptions so the model self-selects correctly.

## GUI

- **AccountsDetail already covers it** (generic accounts page) with one
  addition: an `access` badge per account row (`read-only` tag when the
  grant lacks `spreadsheets`) — same affordance as HubSpot's portal rows.
- Add-modal: One click | Manual pills with the read/write radio on the
  one-click pane — generalize `HubSpotOneClick` into the access-tier variant
  of `GenericOneClick` instead of a third bespoke pane.
- Row blurb: "Read docs and sheets by link; update sheets with approval."

## Tests

- URL/id parser (doc URLs, sheet URLs, bare ids, gid fragments, rejects).
- Doc flattener (headings/lists/tables → text) on a canned documents.get
  payload; truncation.
- Sheet read: no-range metadata+first-sheet shape; ranged values; cell cap.
- Write tools: routing + approval pinned + read-only-grant error path.
- Broker: per-connector access tier resolution (google_docs write ≠ gmail
  consent pollution), scope replacement not addition.
- e2e: accounts page reuse (access badge render); modal radio → connect body.
- Live drill (together): connect read on account A + write on account B,
  run the training-followups flow against a real Sheet, verify the read-only
  account refuses writes and approvals name the email.

## Build order

1. Broker: per-connector access tiers + google_docs scopes (+tests).
2. Desktop: descriptor/provider map/URL parser/read tools (+tests).
3. Desktop: write tools + tier gating (+tests).
4. GUI: access badge + one-click radio generalization (+e2e), visual review.
5. Rohit: consent-screen scopes; deploy (terraform approval); joint drills.

## Open questions (defaulted)

- Docs tabs (new Docs API tab structure): wave 1 flattens all tabs in order.
- `gsheet_create` lands in My Drive root (no drive.file scope to place it in
  folders) — acceptable; the tool returns the URL.
- CSV in/out helpers deferred; rows_json is enough for the model.

---

### Openworker Archive Docs IMPLEMENTATION LEDGER (`openworker-archive/docs/IMPLEMENTATION-LEDGER.md`)

# Implementation Ledger — `platform/personas` branch

Running log for the whole `platform/personas` branch, across its workstreams (in order):
**Personas/Permissions/Inbox** (Phases 0–3, ✅ done) → **Messaging refactor** (✅ done) →
**UI Refresh** (in progress — see `UI-REFRESH-KICKOFF.md`).

> **Phase numbers are per-workstream.** "Phase 0–3" below = the *Personas* effort (complete).
> The *UI Refresh* has its **own** Phases 1–5 (`UI-REFRESH-SPEC.md` §9) — when logging UI-Refresh
> progress, write it as **"UI-Refresh Phase N"** to avoid colliding with the Personas phases.

**Purpose.** Single source of truth for *where the implementation stands*, so any session
(or person) can resume without re-deriving context. The design *why* lives in the per-effort docs
(`PERSONAS.md`, `PERMISSIONS-AND-INBOX.md`, `UX-DECISIONS.md`); this file tracks *what's-done /
what's-next*.

**How to use (read this first when resuming):**
1. Check **Current status** below, then the first phase that isn't ✅.
2. Do the next unchecked milestone; make its completion **gate** (the named tests) pass.
3. Update the checkbox + the **Session log** at the bottom, and **commit the ledger** — that
   commit is the handoff to the next session.

Status legend: ⬜ not started · 🟡 in progress · ✅ done. Tests live in `platform/tests/` (pytest).

---

## Current status

- **ALL PHASES (0–3) COMPLETE** — backend, live wiring, and GUI. Local-only, Devika-authored.
  Python: 444 passed (3 pre-existing SDK errors). GUI: full `npm run build` (tsc + vite) passes.
  **First-pass visual review DONE** (Claude, in the running app): all four surfaces verified —
  persona-driven picker (Ops shows), Personas tab (toggles + install), Inbox view, Unattended
  toggle (one-tap confirm → amber on-state). Two correctness fixes applied + verified live:
  toggle reads its persisted state on mount (new `GET /v1/sessions/{id}/unattended`); removed the
  redundant Settings "Surfaces" toggles (now the Personas tab).
- **Phase 4 (UX/IA) — partly built.** DONE + verified live: family-aware Projects, default-first,
  Chat hidden, expand≠switch. DONE in code (not yet screenshot-verified — browser dropped): shared
  top zone (Integrations/Automations above tabs), Pinned band + pin/unpin. Specs in `PERSONAS.md` +
  `PERMISSIONS-AND-INBOX.md`. **Remaining Phase 4 below.**
- **Phase 5 (Messaging ↔ Sessions) DESIGNED, not built** — channel subscription (pub/sub) vs.
  Inbox (request/reply); `ask_user` tool; durable correlation. Spec: `MESSAGING-AND-SESSIONS.md`.

> ## 🧭 RESUME HERE (next session, after compaction) — remaining-work plan
>
> Worktree `/Users/rohit/fleet/ro4d/aisuite-personas`, branch `platform/personas` (local-only,
> Devika-authored, 0 behind origin/main). Test runner: `./.runtests.sh`. Servers may be running:
> backend `127.0.0.1:8765`, vite `localhost:1420` (restart backend after Python changes; vite
> hot-reloads). Sample persona for the install demo: `~/coworker-personas/research-analyst.md`.
>
> ⚠️ **Run the SERVER under the aisuite venv, not the agent-platform (test-runner) venv.** The
> agent-platform venv lacks the `anthropic`/`google-genai` SDKs, so a server started under it
> errors *every* turn with `ModuleNotFoundError: No module named 'anthropic'` (the default model is
> `anthropic:claude-opus-4-8`). Correct launch:
> `PYTHONPATH="…/aisuite-personas/platform:…/aisuite-personas" …/aisuite/platform/.venv/bin/python
> -m coworker.server.run --port 8765`. (Tests still use `./.runtests.sh` / the agent-platform venv.)
>
> **Ordered plan:**
> 1. ✅ **Phase 4 (frontend)** — attention/liveness badges, family-aware App gating, Search→top,
>    Inbox→session link (answer-in-context B). Committed `d0b64d6`. Dots + Inbox badges seen live by
>    Rohit. **Pending visual:** the Inbox→session link + inline answer card — a real review session
>    is left suspended for this (see hands-on testing below).
> 2. **Phase 5 messaging (per `MESSAGING-AND-SESSIONS.md`):**
>    - ✅ **`ask_user` tool** (commits `181260b`, `238b0c4`) — engine-intercepted like
>      `request_directory`. **Mode-aware routing (the key contract):** Unattended → KIND_QUESTION
>      Inbox item + suspend on `inbox.wait` (`manager.inbox_question_asker`, also the default for
>      background/self-wake runs); **attended → a live `question_requested` event answered inline
>      from the composer (`question_response`), NO Inbox item** — the Inbox is for Unattended only.
>      Question + optional quick-reply `options` + free-text escape + `multi` (mirrors Claude Code's
>      AskUserQuestion); `InboxItemCard` renders option chips for both the inline live prompt and the
>      Inbox. Tested e2e both ways (attended → live event, no Inbox; unattended → Inbox, no event):
>      PASS. **Not yet done:** durable `(session_id, tool_call_id)` capture (still live-only).
>    - ✅ **Unified interactive prompts on parked Inbox items** (commit `1e97e0f`). All four prompts
>      (approval / question / directory / plan) are parked items awaited via `inbox.wait` (the
>      per-connection asyncio queues are gone), so they **survive a dropped socket** (redelivered on
>      reconnect) and resolve from any surface. **`visibility` decides where a prompt shows, not how
>      it works:** attended → inline only (the cross-session Inbox list filters `inline` out, so the
>      **Inbox = explicit Unattended only**); unattended → cross-session Inbox + answer-in-context.
>      Backend: `InboxItem` gains `visibility`+`data`+`directory`/`plan` kinds; live WS responses
>      resolve the session's single pending item, REST resolves by id; approver takes both
>      vocabularies. Frontend: `InboxItemCard` renders directory/plan; live cards suppressed when
>      Unattended (App tracks it via ref + toggle `onChange`). Verified e2e (approval + ask_user,
>      both modes). **Boundaries (honest):** (1) *Layer 2* — a turn **started by** a socket that
>      drops mid-stream still dies on the next send (its event stream is bound to that socket);
>      re-attaching a live turn to a new socket = the deferred durable-resume / event-routing work.
>      Server-*triggered* turns (self-wake, schedule, message) aren't socket-bound, so they park+resume
>      fine. (2) live WS resolve uses "the session's one pending item" (safe: agent blocks on one at a
>      time; a human can't out-race item creation). (3) directory grant from the Inbox/reconnect has
>      no folder picker yet (uses the suggested path).
>    - ✅ **Channel subscription (inbound)** (commit `66208e0`). `SubscriptionStore` (persisted
>      `(session_id, channel)`, permanent until unsubscribe/delete); agent tools `subscribe_channel`
>      / `unsubscribe_channel` / `list_subscriptions` / `get_channel_messages` (ring buffer); agent
>      bootstraps via `ask_user`, parses Slack `<#id|name>`. Gateway dispatch: token → Inbox; channel
>      msg → buffer + fan out to subscribers via `manager.deliver_to_session` (busy→steer/idle→turn,
>      shared with self-wake); DM → super-agent. Loop prevention free (adapter drops bot-self). v1
>      filter = the subscription itself; mention/thread filtering deferred. **Sessions are durable
>      (never end except explicit delete)** — documented in `MESSAGING-AND-SESSIONS.md`. Tested
>      (store/parsing/buffer/tools/dispatch fan-out). **Not yet:** GUI to view/manage subscriptions;
>      `@bot` mention surfacing in `MessageEvent`; channel name→id picker; live Slack e2e (needs a
>      connected workspace — logic is unit-tested).
>    - ✅ **Interactive Inbox prompts over Slack** (commit `9d8798a`). Mirrored items render as Block
>      Kit cards with **buttons** (approve/deny, ask_user options); the item id rides in each button
>      value, so a click resolves the exact item (no `[ocw:id]`-reply fragility). Socket-mode action
>      callback → `manager._on_interaction` → `inbox.resolve` → swaps buttons for the outcome.
>      **Free text isn't offered over messaging — open the app** (token = legacy fallback).
>      Provider-agnostic `Button`; v1 = single-select, Slack. Unit-tested. **Not yet:** Telegram
>      inline keyboards, plan/directory buttons, live Slack click round-trip (needs Interactivity
>      enabled on a connected workspace).
> 3. ✅ **Hands-on testing (done 2026-06-27, via WS+REST against the running server, real model):**
>    (A) self-wake — `sleep_for` → scheduler `resume_due_wakes` resumed the session in ~16s: PASS.
>    (B) Unattended — a `write_file` approval parked in the Inbox (real `attention=1`, liveness
>    `working`), resolving it released the suspended agent and the file was written: PASS. **Not yet
>    tested:** the free-text `ask_user`/KIND_QUESTION path (doesn't exist until Phase 5 — only the
>    KIND_APPROVAL path is wired). A **review session `handson-unatt-6a3fed71`** is left suspended
>    with a real pending approval so Rohit can see the badge + Inbox→session link + inline card in
>    the GUI (resolve/dismiss it from the Inbox to clean up).
>    - ✅ **Durable resume** (commit `08f3bc4`). A prompt (approval/question/directory/plan)
>      pending when the process dies now continues its turn on answer, instead of orphaning. Inbox
>      items are idempotent by `(session_id, tool_call_id)` (id persisted) + the thread is saved when
>      a prompt suspends; `engine.resume()` re-drives ONLY the trailing assistant message's
>      *unanswered* tool-calls — callbacks find the resolved item (no re-prompt), approvals
>      re-execute via the normal authorize→execute path, answered calls are skipped (no double-exec).
>      `manager.resolve_inbox` (REST + Slack button) durably resumes when the agent isn't live;
>      Inbox-based default approver/directory/plan callbacks added so a rebuilt no-socket engine can
>      resolve all four. Tested: question (inject answer) + approval (re-execute tool) survive a
>      simulated restart. **Boundaries:** the legacy token-text-reply path (`_resolve_inbox_reply`,
>      sync) does NOT trigger durable resume — buttons + REST do; the same-process socket-drop case
>      is still the Layer-2 limitation (orphaned task completes server-side, events lost).
> 4. **Messaging GUI** — ✅ **view-only** (`4b0e828`) + ✅ **manage** (`<this>`). Global "Channel
>    subscriptions" table in Integrations (session → inbound channel + outbound routing + collision
>    warning) with per-row unsubscribe + an add form (session select + ChannelPicker + Subscribe);
>    per-session plug chip in the composer head opens a popover to add/remove this session's
>    channels. `ChannelPicker` = datalist of recently-seen channels (`GET /v1/channels/recent`) +
>    free typing (`slack:C…` or `#mention`). REST: `POST /v1/subscriptions`, `/v1/subscriptions/
>    remove`. Tested. **Browser down on my end → Rohit verifies visually.** (Recent list is empty
>    until Slack messages arrive — type `slack:Ctest` to exercise the UI.)
> 5. **deepseek-chat picker bug — TABLED** (Rohit, 2026-06-28). The quick filter patch was built +
>    reverted; the real fix is a provider-model redesign (first-class OpenAI-compat providers) — see
>    "Known bug" below. Current behavior kept (harmless cosmetic leak).
>
> Design items parked (not now): per-coworker integrations (Connect-global vs Grant-per-persona);
> white-label build tooling; wake budgets/runaway detection.
- **Marketplace (decided with Rohit 2026-06-27):** NOT a hosted service. Personas load from a
  local dir or a GitHub repo and are **snapshotted into our managed area** at install (done).
  Built-in Code/Ops stay bundled/immutable. Updating a persona can change live sessions' prompt/
  tools — accepted, noted, deferred. (White-label build tooling: deferred.)
- **Live wiring progress:**
  1. ✅ Approver-swap — the `/ws/session` approver routes to the Inbox + suspends when the
     session is Unattended (reuses the tested inbox machinery; resolved via `/v1/inbox/.../resolve`).
  2. ✅ Scheduler/engine **resume** — no engine surgery needed (Rohit's framing): a self-wake
     tool records a wake + the agent ends its turn; the scheduler tick (`extra_tick`) calls
     `resume_due_wakes()`, which delivers the wake message **busy → `queue_steering` into the
     live turn; idle → run a fresh background turn** (the super-agent's proven pattern). Per-session
     in-flight flag set by `/ws/session`. Self-wake tools registered for knowledge personas.
  3. ✅ Gateway inbound — `Gateway.reply_resolver` consumes inbound messages carrying an
     `[ocw:<id>]` token (resolve the item + release the suspended agent) instead of routing them
     as a new turn; the unattended approver mirrors the approval out to the bound Slack/Telegram
     channel with the token embedded. Bidirectional loop closed.
  4. ✅ **GUI pass** (build-verified; visual review pending): Personas tab in Manage modal
     (enable/surface/set-default + install from dir/GitHub with consent summary); the new-session
     **picker is now driven by surfaced personas** (Ops appears), static set as fallback; an
     **Inbox view** (approve/answer/dismiss, polled) wired as a sidebar surface; a per-session
     **Unattended toggle** in the composer head (one-tap confirm). `api.ts` extended. Full
     `npm run build` passes. *Deferred polish for review:* composer-disable while unattended;
     inbox routing config UI; reactive persona refresh after enable; inbox unread badge.
- **Base:** branch `platform/personas` off `origin/main` (local-only, Devika-authored).

**Dev env (how to run tests):** no venv in this worktree — reuse the `agent-platform` venv
interpreter with this worktree on `PYTHONPATH` (helper: `./.runtests.sh <pytest args>`, which
runs `…/agent-platform/platform/.venv/bin/python -m pytest` with
`PYTHONPATH=<root>/platform:<root>`). That venv lacks the `anthropic`/`google-genai` SDKs and
`black`, so 3 provider tests error on import (pre-existing, unrelated) and formatting is by
hand. Everything else runs green.

---

## Phase 0 — Foundation: tool catalog + risk classes

*Goal: a declarative `id → capability` catalog + risk-class-driven permissions, with **no
behavior change** for existing agents. Both personas and permissions sit on this.*

- ✅ `risk.py` — `RiskClass` (read/write_local/exec/external) + `classify()` with a user-local
  override seam; `permissions.py` reads it instead of `WRITE_TOOLS`/`SHELL_TOOL`/`requires_approval`
  (those re-exported for back-compat).
- ✅ `catalog.py` — capabilities (`code_files`, `files`, `git`, `search`, `shell`, `todo`) with
  `requires` + `risk`; `expand(ids, ctx)` skips capabilities whose context is absent.
- ✅ `agents/code.py` + `agents/cowork.py` build via `expand(...)` (hand-written factories gone).
- ⬜ `never_unattended_auto` floor flag — deferred to Phase 2 (where it's consumed).

**Completion gate (tests): ✅ 41 passed.**
- `test_catalog.py` — expand reproduces the Code & Cowork toolsets exactly; requirement
  skipping; the single-root vs multi-root file-capability distinction preserved.
- `test_permissions_risk.py` — `classify` mapping (incl. `external` + override precedence) and
  engine decisions across all 5 modes.
- Existing `test_tools_permissions.py` + full suite green (348 passed; 3 pre-existing SDK-import
  errors unrelated).

**Note:** `read_file_lines` stays Cowork-only (Code folds it into the windowed reader) — the
two file capabilities (`code_files` single-root numbered vs `files` multi-root) preserve each
surface's exact toolset.

## Phase 1 — Personas core

*Goal: personas as data; Code/Cowork become manifests; a session is born from exactly one
persona; lifecycle toggles in settings.*

- ✅ `personas/manifest.py` — `PersonaManifest` + strict parser/validator (YAML frontmatter +
  markdown body); `to_agent()` → catalog-expanded Agent with traits. (pyyaml added to deps.)
- ✅ `personas/registry.py` — `PersonaRegistry`: builder-backed core (Code/Chat/Cowork keep exact
  prompts) + markdown built-ins; lifecycle (`enabled`/`surfaced`/`default`) persisted to
  `<data>/personas.json`; module singleton installed by the manager. `agents/registry.get_agent`
  delegates here (lazy import; MyHelper still direct).
- ✅ Ops persona shipped as a markdown manifest (`personas/builtin/ops.md`) — dogfoods the parser.
- ✅ Agent traits (`family`/`messaging`/`connectors`) replace the `agent.name == …` branching in
  `build_engine` + `manager.get_engine` (so Ops behaves like Cowork: orphan scratch, connectors,
  scheduling, request_directory). Session binding/pin/rename ride existing `SessionRecord` fields.
- ✅ REST: `GET /v1/personas`, `POST /v1/personas/{id}` (enable/surface/default); `/v1/agents`
  now returns the surfaced persona picker.
- ⬜ **GUI deferred** to a visual-review pass (persona picker + Personas settings tab) — backend
  + endpoints ready; React UI to be built with Rohit per the visual-review rule.

**Completion gate (tests): ✅ 28 passed.**
- `test_persona_manifest.py` — valid parse + 8 rejection cases (no/again frontmatter, missing id,
  empty body, unknown tool, bad family/workspace/mode); `to_agent` traits + tools.
- `test_persona_registry.py` — lifecycle + set-default; Cowork-disabled → fallback; surface toggle
  filters the picker but keeps the persona resolvable; state persists.
- `test_builtin_personas.py` — Code/Cowork personas resolve to the **exact** builder toolsets;
  Ops composes the knowledge toolset.
- `test_session_persona.py` — persona recorded on the session + stable across reload; pin + rename
  persist.
- Full suite: 404 passed (1 prior test updated: unknown-agent fallback is now the default persona
  Cowork, not Code); same 3 pre-existing SDK-import errors.

## Phase 2 — Distribution + autonomy + in-app Inbox

*Goal: third-party personas (local dir / git URL) with consent; effective-risk overrides;
Unattended + suspend/resume + self-wake; the in-app Inbox.*

- ✅ `personas/loading.py` + registry `install_from_dir`/`install_from_git` (injectable clone);
  `consent_summary`; installed sources persist + reload; third-party personas land
  disabled+unsurfaced pending consent. REST `POST /v1/personas/install`.
- ✅ `overrides.py` — user-local `RiskOverrideStore` (glob, most-specific wins), wired into
  `build_engine`'s PermissionEngine. Never written by persona loading (no-self-grant).
- ✅ `unattended.py` — per-session toggle registry (persisted). REST
  `POST /v1/sessions/{id}/unattended`. (Composer-disable + one-tap confirm = GUI, deferred.)
- ✅ `inbox.py` — `InboxStore` (3 kinds), `pending→resolved` state machine (idempotent,
  first-responder-wins), `reconcile_on_resume`, async `inbox_approver`. REST `GET /v1/inbox`,
  `POST /v1/inbox/{id}/resolve`, `GET /v1/inbox/reconcile`.
- ✅ `selfwake.py` — `WakeStore` (timer + on-completion) + `due()`/`complete_job()` + the
  `sleep_for`/`sleep_until`/`wake_on` tools.
- ⬜ Live wiring (carried forward): approver-swap when Unattended; scheduler resuming due wakes; GUI.

**Completion gate (tests): ✅ 25 passed.**
- `test_persona_loading.py` (5) — dir + git-URL load; consent summary; disabled-pending-consent;
  persistence; invalid manifest fails loud.
- `test_risk_overrides.py` (6) — most-specific glob wins; relaxes MCP in classify + engine;
  persistence; tighten direction; **no-self-grant** (manifest override/elevated-mode ignored).
- `test_inbox.py` (7) — kinds/filter; idempotent first-responder-wins; persistence; reconcile;
  approver allow/deny.
- `test_unattended.py` (2) — toggle + persist; unattended routes approvals to the Inbox.
- `test_self_wake.py` (5) — timer due-after-fire; completion due-after-complete; mark_fired;
  persistence; tools.

## Phase 3 — Channels + events + marketplace

*Goal: Inbox mirrored to Slack/Telegram (bidirectional); on-event wake; registry/marketplace;
white-label builds.*

- ✅ **Marketplace = dir/GitHub load + snapshot** (see decision above): `install_from_dir` now
  copies manifests into `<data>/personas-installed/<id>/` (stable, source-independent);
  `install_from_git` clones then snapshots. Built-ins bundled/immutable.
- ✅ `inbox_routing.py` — named inboxes + bindings (in-app / Slack / Telegram); `route_for`
  (session override > persona default > default); `deliver` (embeds `[ocw:<id>]`); 
  `resolve_from_reply` (correlate inbound reply → resolve). REST `GET /v1/inbox/routing`,
  `POST /v1/inbox/routing/binding`.
- ✅ On-event wake: `WakeStore.add_event` / `fire_event` + the `wake_on_event` tool.
- ⬜ White-label build tooling — deferred.
- ⬜ **Live wiring + GUI** — see Current status (the remaining work).

**Completion gate (tests): ✅ 13 passed (+ snapshot test).**
- `test_inbox_routing.py` (7) — route precedence; persisted bindings; deliver embeds item id;
  in-app-only delivers nothing; inbound reply resolves the right item (approve / free-text);
  tokenless reply ignored.
- `test_self_wake.py` — on-event due-after-fire + the `wake_on_event` tool.
- `test_persona_loading.py` — snapshot survives source deletion.
- Full suite: **438 passed**, 1 skipped, same 3 pre-existing SDK-import errors.

## Phase 4 — UX / IA polish (built 2026-06-27)

*Goal: make the persona model feel coherent in the GUI. All frontend (+ one backend field); design
settled in `PERSONAS.md` (Family, Sidebar IA) + `PERMISSIONS-AND-INBOX.md` (attention/liveness).*

- ✅ **Family-aware frontend** — Sidebar Projects/grouping keys off `familyOf(id) === "code"`;
  App's `needsWorkspace`/`gatesWorkspace` now read the persona's `needs_workspace` / `family ===
  "code"` (id-based fallbacks only until personas load). A code-family *third-party* persona now
  gates a folder like Code; a knowledge persona starts orphan like Cowork.
- ✅ **Sidebar default-first + Chat hidden** — registry: Cowork registered first; Chat
  `default_surfaced=False`. Verified live: picker = OpenCoworker, Code, Ops; no Chat.
- ✅ **Expand ≠ switch** — header toggles the accordion only (`browseKey = openKey`); chat area
  changes on select/New. Verified live.
- ✅ **Attention badges** — `list_sessions` now returns `attention` (count of pending Inbox items
  for the session). Amber **count** renders on the session row → persona header → footer Inbox
  (views of the one Inbox queue; no new tab). Verified with seeded data; honest path needs an
  Unattended session (items only reach the Inbox when Unattended — attended sessions answer inline).
- ✅ **Liveness dot** — `list_sessions` returns `liveness` (`working` = in-flight turn via
  `is_running`, `sleeping` = a self-wake pending via `wakes.pending`, else `idle`). Count-less dot
  (green pulse / grey); never bubbles into the attention count.
- ✅ **Pinned band** — cross-persona band above the accordions, manual pins only; pin/unpin on
  every session row.
- ✅ **Shared top zone** — Search + Integrations + Automations in `.shared-nav` above the persona
  accordions (Search moved to top, first).
- ✅ **Inbox → session link (answer-in-context, "B")** — each Inbox item shows a clickable chip
  (persona icon + session title) that opens its originating session; and the session view renders
  its pending Inbox item inline above the composer (`InboxItemCard`, shared with `InboxView`), so
  the blocking question/approval is answerable in context — resolving the same item id (first
  responder wins). Decided *not* to add the reverse (clickable badge → filtered Inbox) this pass.
- ⬜ Carried deferred polish: **composer-disable while Unattended** + **reconciliation on turn-off**
  (real behaviors Rohit reaffirmed: Inbox items exist only for Unattended sessions; turning
  Unattended off surfaces pending items inline before the session proceeds); inbox routing config
  UI; reactive persona refresh after enable; clickable attention badge → filtered Inbox.

**Completion gate:** typecheck + `npm run build` green ✅. Visual: dots + Inbox badges seen live by
Rohit; the Inbox→session link + inline answer-in-context to be shown next review with a *real*
Unattended session (not synthetic seeding).

### Design item (not Phase 4) — per-coworker integrations

Separate **Connect** (authenticate a connector — *global*, the one Integrations page common to all)
from **Grant** (which connected integrations a coworker may use — *per-persona*). Evolve the
manifest `connectors` from a boolean to a **list of connector ids** the persona wants; a coworker
sees only connectors it declares AND that are connected. Surface a user override in the **Personas
tab** (per-persona connector toggles, with a "Connect" deep-link to the global page). Design later.

### Known bug — phantom `deepseek-chat` in the model picker (TABLED 2026-06-28; fix architecturally)

`deepseek-chat` shows in the composer model selector though there's no DeepSeek provider. Root: a
**bare** `deepseek-chat` in `prefs.json` (added under the OpenAI provider — bare = OpenAI; almost
certainly a pre-#340 default), and `_model_provider` defaults **any** bare name to `openai`, which
is configured → the filter keeps it.

**The deeper cause (why we tabled the quick fix):** OpenAI-compatible services (DeepSeek, OpenRouter,
vLLM, Azure) are modeled as **"OpenAI provider + a custom `base_url`"**, so their models live in the
OpenAI bucket and are stored **bare**. A bare name is therefore **ambiguous** — junk on stock
`api.openai.com`, but a *valid* model on a custom endpoint. You can't tell from the name.

A targeted patch was built + reverted (Rohit's call 2026-06-28): hide a bare name on stock OpenAI
unless it matches an OpenAI naming family (`gpt-`/`o…`/KNOWN_MODELS), allow anything when a custom
`base_url` is set (it correctly used base_url as the stock-vs-compat discriminator, hide-not-delete).
Correct for today's design, but a heuristic on an ambiguous model.

**Decided fix (later, comprehensive):** make OpenAI-compatible services **first-class providers** —
distinct descriptors (DeepSeek / OpenRouter / …) each with its own key + base_url + model list — so a
model is **explicitly** `provider:model` and only shows when that provider is configured. Zero
bare-name ambiguity. Work: new descriptors over the openai-compat build, GUI panes, and a migration
for existing bare names. Until then the phantom is a harmless cosmetic leak; current behavior kept.

---

## Session log

Append one entry per working session (newest at top): date · who/branch · what changed · tests.

- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh — "New project" regression fix + Codex-style Projects.**
  Rohit flagged that the sidebar "New project" line did nothing when clicked, and shared a Codex screenshot of
  its Projects panel. **(1) Regression** (introduced with `isProjectScoped`): the button only set the gate flag,
  but the gate renders behind `surface==="session" && gatesWorkspace(activeAgent)` — so it silently no-op'd
  whenever the *active* session wasn't project-scoped (e.g. browsing Code/Ops from a Cowork session). Fix:
  `onNewProject(browseKey)` → `newProject(persona)` switches to that persona, starts a fresh session, and opens
  the gate in **create** mode. Verified live: `+` from a Cowork session switched to Ops and showed the "New
  project" create gate. **(2) Codex-style Projects (Option A, agreed with Rohit — keep persona-first, polish the
  per-persona Projects sub-section):** "New project" moved from a list row to a **`+` in the Projects header**;
  each folder is now **collapsible** (active project open by default, else the most-recent); session rows carry a
  right-aligned **compact age** (`compactAge`: now/5m/6h/3d/2w/4mo/2y); folders truncate to **5** with a
  **"Show more"** disclosure; search expands matching folders and hides the rest. `tsc`+`build` clean, vitest
  20/20. Commits `60f2d44` (regression), `2d016c5` (Projects). **Note:** the lone existing Ops folder shows a
  scratch-dir hash (`4cfb0fb3-46a`) — pre-change orphan data; new projects get the real folder name.
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh visual-parity — post-review fixes (Rohit's eyeball).**
  After Rohit reviewed the Tailwind port: (1) **Sources bar was invisible** — it rendered as a sibling before
  the workspace, so it sat at y=0 behind the `position:absolute` glass topbar; moved it inside the chat column
  (which already pads to clear the topbar) as a fixed sub-header. Now SOURCES + the connector avatar stack show
  under the title and open the Session-connections drawer. (2) **Connector cards confirmed working** — drove a
  live FakeSlack message into a subscribed session; it rendered as a brand-tinted card with resolved names
  (`ocw-test` / `Alex Rivera` via the real users.info/conversations.info). The "legacy-looking" messages were
  pre-Phase-2 data (no `source`); new ones card correctly. (3) **Markdown restyle** — visible list markers +
  real item/heading spacing (lists were run-together), zebra/tinted tables, refined inline code. (4) **Dropped
  the redundant per-persona "New session"** (top split button covers it; mock omits it). (5) Fixed the persona/
  drawer icon rendering a raw logo-id (`cowork`) instead of a glyph. `tsc`+`build` clean, vitest 20/20. Commits
  `776737a`, `abeaba0` (+ the surface ports). **Note:** a demo connector message + a denied turn were left in
  the local `hi` session by the live card test — delete if unwanted. The `_say is not a valid argument` Slack
  log is benign (message delivered, names resolved).
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh visual-parity pass — port the GUI to the mock (Tailwind).**
  Rohit's feedback after the phases landed: functional but "a far cry from the mocks." Root cause: the app and
  `ui-mocks/redesign.html` share the exact CSS tokens, but the mock is Tailwind and the app was hand-CSS, so
  per-component polish was never ported. **Added Tailwind v3** (config mirrors the mock; color tokens wrapped in
  `color-mix` so the mock's `/NN` opacity utilities work; imported before styles.css so un-ported surfaces keep
  their CSS). Then ported all four surfaces to the mock markup, surface by surface, each screenshot-verified in
  the running app: **session view** (topbar pills, sources bar, brand-tinted connector cards, StepGroup,
  composer card, dark bubbles; dropped the dotted main bg) · **sidebar** (wordmark + layout toggle, split
  button + persona menu, pinned/recent + grouped cards, bottom nav, footer) · **persona page + sources drawer**
  (identity/enable, recommends with connect state, default-connection toggles; logo-id icons now render a glyph)
  · **Integrations** (Connectors/Messaging/Activity/MCP sub-nav + connector-card grid with real brand badges).
  Behaviour unchanged throughout; `tsc`+`build` clean, vitest **20 passed** at every step. 6 commits, local-only,
  Devika-authored. **Not visually verified (code-complete + test-covered):** the live connector card (needs a
  real connector message), the Sources drawer (needs opening), the Ops persona's recommends sections (needs an
  Ops session to navigate to). **Dev note:** the tailwind.config change needs a dev-server restart — a stale
  vite won't show it (review on a freshly-started dev server).
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh Phase 5 — frontend polish + the e2e merge gate.**
  Frontend (§7): Integrations restructured into a left sub-nav (Connectors · Messaging routing · Activity ·
  MCP); a dual sidebar (flat ↔ grouped-by-persona, persisted via a new `nav_layout` pref, grouped cards carry a
  gear → PersonaView); a New-session split button (primary = last persona, ▾ = enabled personas + "Manage
  personas…"); and a `StepGroup` that collapses tool/approval items into "N actions · M approvals ✓".
  `ConnectorBadge` (real brand color/logo) now also renders on the connector rows. Backend: `nav_layout` pref
  (`GET /v1/settings` + `POST /v1/settings/nav-layout`). **Product bug found + fixed by the e2e:** the async
  `SlackAdapter.send`/`send_interactive` wrapped the **blocking** httpx senders and were awaited directly on the
  server loop (mirror/interaction paths) — freezing all sessions/sockets for the Slack round-trip; now offloaded
  via `asyncio.to_thread` (matches the engine's send path). **The merge gate** `test_ui_refresh_e2e.py` drives
  the WHOLE refresh against FakeSlack with the **real** SlackAdapter/slack_bolt stack: connect → channel message
  becomes a structured connector card with names resolved via real `users.info`/`conversations.info` (provider
  gets framed text, no `source`) → Unattended approval mirrored as a real Block Kit card → inject Approve →
  durable resume + reply posts back → mute drops delivery (still buffers) → attention == unconnected recommends.
  Tests: e2e + nav_layout → Python **500 passed** (3 pre-existing SDK errors); vitest **20 passed**; `tsc`+`build`
  clean. State-dir isolated (real secrets hash byte-identical across a full-suite run). Local-only, Devika-authored.
  **UI-Refresh Phases 1–5 + FakeSlack COMPLETE.** Remaining: Rohit's visual review of the GUI; deferred polish
  (Integrations 2-col card grid; connector brand color on the transcript `ConnectorMessageCard`; session `detail`
  channel names need the adapter cache); push (Rohit).
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh Phase 4 — persona + session connection surfaces.**
  Backend (5 endpoints): `GET /v1/personas/{id}` (identity + tools + recommended_models + permission mode +
  workspace + `recommends` with `connected` annotated + `default_connections`); `POST /v1/personas/{id}/
  connections` (set persona default); `POST /v1/personas/{id}/enable` (delegates to the registry);
  `GET /v1/sessions/{id}/connections` (effective `connected` w/ a detail string + `recommended` not-yet-connected
  + `attention` count); `POST /v1/sessions/{id}/connections` (session override, `clear` to inherit). Frontend:
  `PersonaView` (identity + Enable toggle + capabilities + recommends + "new sessions get by default" toggles),
  `SourcesBar` (avatar stack + ⚠ N) + `SourcesDrawer` (per-session connection toggles), a shared `Toggle`, and
  `connectors/visuals.ts` threading **real brand_color/logo** from `/v1/connectors` into these surfaces (closes
  the Phase-2 neutral-gray follow-up for the connection surfaces; the transcript `ConnectorMessageCard` is still
  neutral — its caller doesn't thread a color yet). SourcesBar mounted under the session topbar; PersonaView
  reachable via a topbar "About this persona" button + the drawer link (grouped-nav gear + New-session "Manage
  personas…" entry = Phase 5). **Design call (per Phase-3 review):** persona `default_connections` lists the
  recommended defaults (spec-literal); the *session* `connected` list shows ALL effective connectors (honest
  where "why is this on" matters). Tests: `test_persona_connections.py` (5, isolated via `COWORKER_STATE_DIR`)
  → Python **498 passed** (3 pre-existing SDK errors); vitest **17 passed**; `tsc`+`build` clean.
  **Test-hygiene fix (separate commit):** isolated `test_connections.py` (Phase 3) — it built a real
  `SecretStore` against the global state dir and wrote a fake `github:default` into the developer's real
  `~/.config/coworker/secrets.json`. Now pinned to a tmp `COWORKER_STATE_DIR` (verified: global secrets hash
  unchanged across a run). **Known/flagged (pre-existing, needs Rohit):** other tests (`test_attachments`,
  `test_mcp`, historically `test_connectors`) still write to the real state dir; a fake `github:default`
  (`ghp_test`) currently sits in the real secrets store (deletion was correctly blocked — pending Rohit's call).
  Deferred: session `detail` shows chat ids (names need the live adapter cache); `recommended` excludes mcp.
  **Next: Phase 5 (frontend polish + e2e gate).**
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh Phase 3 — connection hierarchy (load-bearing data model).**
  New `connections.py`: `PersonaConnectionStore` (per-persona default on/off, seeded from manifest `recommends`)
  + `SessionConnectionStore` (per-session overrides) + a pure `effective()` resolver — **connected AND
  (session-override if present, else persona-default, else inherit-on)**. Manager owns both stores +
  `effective_connectors(session)`; runtime gating in two places: connector **tools** (a `connector_filter`
  threaded into `build_engine`, applied in BOTH `get_engine` and `_build_task_engine`) and **inbound delivery**
  (`_dispatch_inbound` channel + DM paths skip a muted connector but still buffer). `delete_session` drops the
  session's overrides. **Adversarially reviewed before commit** — the resolver was proven correct; the review
  caught + we fixed: (1) MAJOR seed-staleness — a `tier:core` connector seeds **on** regardless of current
  connectedness (effective()'s connected-gate is the single source of truth), so it self-lights when later
  connected instead of being frozen off (intentional deviation from §4.2's literal wording, documented in
  code); (2) unified the inbound gate onto `effective_connectors` (was a seed/no-seed asymmetry); (3) the
  scheduled-task engine builder now also applies the filter. Tests: `test_connections.py` (8, incl.
  connect-after-seed self-lighting + the muted-not-delivered/tools-absent/DM-muted gates) → Python **493
  passed** (3 pre-existing SDK errors). Local-only, Devika-authored.
  **Phase 4 design note (decided, to implement in §5):** since unrecommended-but-connected connectors inherit
  *on*, the persona-detail `default_connections` will enumerate **all connected connectors** with their
  effective default (keep inherit-on, make the UI honest) rather than special-casing built-in personas.
  **Next: Phase 4 (persona + session connection surfaces).**
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh Phase 2 — structured connector messages.**
  Connector inbound messages now carry a display-only `source` sidecar so the GUI renders a rich card while
  the model still gets the framed text. Backend: `MessageSource` dataclass (§3.1); `engine.run(..., source=)`
  stores it on the user message and emits it in `TURN_START`; **`_outbound_messages` strips `source`
  unconditionally** (the sole provider feed — proven by a no-context strip test); `deliver_to_session` +
  `_dispatch_inbound` build+thread the source on both channel and DM paths (steering path too); persisted
  verbatim (store json-dumps each message) so `GET /messages` + WS `turn_start` surface it for free. Frontend:
  `ConnectorMessageCard` (brand-tinted header/edge, names with id-on-hover swap, relative time); extracted
  `itemsFromMessages.ts` (testable) maps `source.connector` → a `connector` item; `Transcript` renders it.
  Tests: `test_message_source.py` (5, incl. persisted-and-stripped) → Python **485 passed** (3 pre-existing
  SDK errors); vitest **11 passed**; `tsc`+`build` clean. Updated 3 existing tests for the new
  `deliver_to_session`/`_outbound_messages` contract (signatures + `is`→`==`). Local-only, Devika-authored.
  **Deferred (small follow-up, tracked for Phase 4):** connector cards render in neutral gray — `source`
  carries only the connector id, not `brand_color`; a `brandColor` prop hook exists to thread the real color
  (from `/v1/connectors`, which Phase 1 added) once the session view loads connector data. **Next: Phase 3
  (connection hierarchy — the load-bearing data-model change).**
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh Phase 1 — connector registry metadata + contract.**
  Backend: `ConnectorDescriptor` gains `brand_color`+`logo`; `connector_list` surfaces them; placeholder
  `available:false` descriptors for the not-yet-shipped recommended connectors (datadog/salesforce/pagerduty
  — github+hubspot already ship here, so they got brand metadata, not duplicate placeholders); `SlackAdapter`
  gains cached `_channel_name` (conversations.info) + public `resolve_user_name`/`resolve_channel_name`
  wrappers, and populates a new `SessionSource.chat_name` in `_on_message` (mirrors `user_name`). Frontend:
  `src/connectors/registry.tsx` (logo-id → inline SVG + `FALLBACK`) and `ConnectorIcon`/`ConnectorBadge`
  (brand color from API, fallback plug on unknown id); `Connector` interface gains `brand_color`/`logo`. Stood
  up a minimal **vitest + @testing-library/react** harness (separate `vitest.config.ts`, devDeps only — reused
  by later frontend phases). Tests: `test_connector_registry.py` + `test_slack_resolve_channel_name` (Python
  **480 passed**, 3 pre-existing SDK errors); vitest 4/4; `tsc --noEmit` + `npm run build` clean. Local-only,
  Devika-authored, not pushed. **Next: Phase 2 (structured connector messages).**
- **2026-06-29** · Devika / `platform/personas` · **UI-Refresh: FakeSlack harness (foundation, build-first).**
  Built `coworker/testing/fake_slack/` — an in-process Slack test double on Starlette+uvicorn (ephemeral
  port): Web API (`auth.test`/`apps.connections.open`/`users.info`/`conversations.info`/`chat.postMessage`/
  `chat.update`), Socket Mode WS speaking real-`slack_bolt`-shaped `events_api`+`block_actions` envelopes,
  a `/control/*` HTTP API + programmatic `FakeSlack` object, and a standalone `python -m
  coworker.testing.fake_slack` runner. One production change: a `SLACK_API_URL` base-URL override on the
  Slack adapter (bolt `AsyncWebClient`) **and** both httpx senders (default = real Slack). `aiohttp>=3.9`
  declared in the `messaging` extra (so CI installs it) + pip-installed into the test venv. Added the
  `fake_slack` pytest fixture (`tests/conftest.py`, monkeypatched env). Tests: `test_fake_slack.py` 6/6
  incl. a guard that the **real** `AsyncSocketModeHandler` dispatches both fake-sent envelope shapes;
  full suite **477 passed** (3 pre-existing SDK-import errors). Local-only, Devika-authored, not pushed.
  **Unblocks UI-Refresh Phases 1–5** (the integration/e2e tests run the real adapter against this fake).
- **2026-06-28** · Devika / `platform/personas` · **Live Slack re-test + 5 follow-up fixes.** Ran the
  full messaging re-test end-to-end against real Slack (after re-auth). **Verified live:** super-agent
  retired; live channel reaction (per-session event bus — the original bug); error visibility
  (Unrouted panel); DM routing (park + deliver); allow-list/recent-senders UI; real inbound→reply
  round-trip; and the marquee **Unattended approval → Slack Block Kit buttons → durable resume**
  (needed Slack "Interactivity" enabled). Added a new **"Unattended approvals → channel" routing UI**
  (`99a8748`) and fixed bugs the test surfaced: reconnect clobbering a stored token/allow-list with
  the masked placeholder (`d3cb0a2`), recent senders showing "unknown" → resolve via users.info
  (`b978044`), approval card missing tool args (`b4a822d`), and Slack setup docs missing the
  Interactivity toggle + users:read (`c6f4669`). Each fix has tests; full suite 466 passed (3
  pre-existing SDK errors). Note: an env-gated `COWORKER_DEBUG_INJECT` endpoint (feeds the real
  inbound path without a live bot) rode along in `b4a822d`; off by default. **Remaining:** push (Rohit).

- **2026-06-28** · Devika / `platform/personas` · **Messaging refactor — 5 fixes (4 commits).** Acting
  on the gaps the live Slack channel-subscription test surfaced. **Fix 1** per-session event bus
  (`_session_clients` + `broadcast_session`): background turns (channel delivery, self-wake, durable
  resume) now stream live to any open socket instead of being discarded; `turn_start` surfaces the
  inbound message as a user item. **Fix 2** error visibility: `unrouted.py` dead-letter store —
  background-turn ERRORs + undeliverable inbound are logged + parked (`GET /v1/unrouted` + Integrations
  panel) instead of vanishing. **Fix 3** allow-list onto the (reachable) Connectors tab: `/v1/connectors`
  now carries `allowed_users` + recent senders; ported the allow/recent UI from the orphaned super-agent
  view. **Fix 4+5** DM routing (prefs `dm_session` + `/v1/messaging/dm-route`; DM → designated session
  or parked) **and retired the super-agent** (deleted `connectors/superagent.py`, the `_sa_*` surface,
  `/ws/superagent`, `SUPERAGENT_SESSION_ID`, `SuperAgentView`; `myhelper` kept resolvable). Tests:
  `test_session_events.py`, `test_connectors_allowlist.py`, `test_dm_routing.py` + updated
  subscriptions/connectors (462 passed, 3 pre-existing SDK errors). GUI tsc clean. **Remaining =
  Rohit's visual review + live Slack re-test (per the plan's E2E checklist).**
- **2026-06-27** · Devika / `platform/personas` · **Messaging↔sessions design + plan (pre-compact).**
  Wrote `MESSAGING-AND-SESSIONS.md` (channel subscription vs Inbox; one-bot-identity mention model;
  `ask_user`; durable correlation best-effort→hardened). Added the "RESUME HERE" remaining-work plan
  to Current status. Also committed this session: shared top zone (Integrations/Automations) +
  Pinned band (code-complete, not screenshot-verified — browser dropped). No new tests this entry.
- **2026-06-27** · Devika / `platform/personas` · **Phase 4 first fixes (verified live).** Sample
  persona `~/coworker-personas/research-analyst.md` (for manual install demo). Backend: default-
  first ordering (Cowork registered first) + Chat hidden by default (`default_surfaced`). Frontend:
  family-aware Projects (`familyOf(id)==="code"`), expand≠switch (`browseKey`), `startNewSession`
  takes a persona. Verified in the running app (screenshots saved): picker = OpenCoworker/Code/Ops
  (no Chat); Code→Projects, OpenCoworker/Ops→Recents; expanding Code/Ops keeps the OpenCoworker
  chat loaded. Tests: persona/server updated (Python 445 passed, 3 pre-existing SDK errors); GUI
  `npm run build` green. **Remaining Phase 4: Pinned band, attention/liveness badges, family-aware
  App gating — stopped here for Rohit's manual review.**
- **2026-06-27** · Devika / `platform/personas` · **UX pass (design + spec only).** Settled the
  family model (binary workspace model; roles compose; Code stays a persona; frontend must be
  family-aware), the "Pinned band on top" sidebar IA (expand≠switch, default-first, Chat hidden,
  pins = pure accessibility, no "always-on" mode), and attention-vs-liveness sidebar indicators
  (attention count bubbles to the Inbox; liveness is a count-less dot; no new tab). Updated
  `PERSONAS.md` + `PERMISSIONS-AND-INBOX.md` + added Phase 4 here. No code yet.
- **2026-06-27** · Devika / `platform/personas` · **First-pass visual review + 2 fixes.** Drove
  the running app: all 4 GUI surfaces verified. Fixed the Unattended toggle to read its persisted
  state on mount (added `GET /v1/sessions/{id}/unattended`) and removed the redundant Settings
  Surfaces toggles (superseded by the Personas tab). Verified live; typecheck + server tests green.
  Remaining deferred polish: composer-disable while unattended; inbox routing config UI; reactive
  persona refresh after enable; inbox unread badge.
- **2026-06-27** · Devika / `platform/personas` · **GUI pass done** (4 commits: Personas tab +
  api client; persona-driven picker; Inbox view; Unattended toggle). `npm install` + full
  `npm run build` (tsc + vite) pass. **All phases 0–3 complete.** Remaining = Rohit's visual
  review + deferred polish.
- **2026-06-27** · Devika / `platform/personas` · **Phase 3 wiring done** (3 commits: unattended
  approver-swap; self-wake resume busy→steer/idle→run via scheduler extra_tick; gateway inbound
  correlation + outbound channel mirror). Full suite 444 passed.
- **2026-06-27** · Devika / `platform/personas` · **Phase 3 backend done** (2 commits: snapshot
  install; on-event wake + multi-inbox routing). Marketplace decided = dir/GitHub load + snapshot
  (no hosted service). Added `inbox_routing.py`, `WakeStore` events, manager routing store + REST.
  Tests: inbox_routing/self_wake/loading (13 + snapshot; full suite 438 passed). **Remaining:
  live wiring (approver-swap, scheduler-resume, gateway inbound) + GUI pass.**
- **2026-06-27** · Devika / `platform/personas` · **Phase 2 backend/logic done** (2 commits:
  2a overrides+loading, 2b inbox+unattended+self-wake). Added `overrides.py`, `personas/loading.py`,
  `inbox.py`, `unattended.py`, `selfwake.py`; manager stores + REST. Tests: loading/overrides/
  inbox/unattended/self-wake (25 passed). Live approver-swap + scheduler-resume + GUI carried
  forward. Next: **Phase 3 — ask Rohit about marketplace before building.**
- **2026-06-27** · Devika / `platform/personas` · **Phase 1 backend done.** Added `personas/`
  (manifest + registry), Ops markdown persona, Agent traits replacing name-branching in
  build_engine/manager, `/v1/personas` endpoints. Tests: manifest/registry/builtin/session
  (28 passed; full suite 404 passed, 3 pre-existing SDK errors). GUI (picker + settings)
  deferred to a visual-review pass. Next: Phase 2.
- **2026-06-27** · Devika / `platform/personas` · **Phase 0 done.** Added `risk.py` +
  `catalog.py`; refactored `permissions.py` to risk classes and Code/Cowork to build via the
  catalog. Tests: `test_catalog.py`, `test_permissions_risk.py` (41 passed; full suite 348
  passed, 3 pre-existing SDK errors). Next: Phase 1 manifest + registry.
- **2026-06-27** · Devika / `platform/personas` · Wrote design docs (`PERSONAS.md`,
  `PERMISSIONS-AND-INBOX.md`) + this ledger. No code yet.

---

### Openworker Archive Docs MESSAGING AND SESSIONS (`openworker-archive/docs/MESSAGING-AND-SESSIONS.md`)

# Messaging ↔ Sessions — design

**STATUS: design (draft for review).** Discussed with Rohit 2026-06-27. How a messaging
platform (Slack/Telegram) connects to persona *sessions*: ambient **channel subscription**
(inbound pub/sub) vs. the **Inbox** (targeted request/reply). Companion to `PERSONAS.md`
(sessions, self-wake, no "always-on") and `PERMISSIONS-AND-INBOX.md` (the Inbox, routing,
gateway). Decisions log at the bottom.

## Two mechanisms, opposite semantics

The core insight: connecting messaging to a session is **two different things**, and naming
them apart makes both simple.

| | **Channel subscription** | **Inbox** |
|---|---|---|
| Pattern | publish / subscribe (broadcast) | request / reply (point-to-point) |
| Direction | inbound, ambient | agent asks → user answers |
| Correlation | **none** — messages are just *visible* to subscribers | **critical** — answer must return to one session + one tool call |
| Fan-out | many sessions per channel (a feature) | exactly one waiter per item |
| Agent state | may arrive while working (a steer) or idle (a new turn) | the agent is *blocked* on the answer |

**The discriminator that lets both share one channel:** an inbound message carrying an
`[ocw:<id>]` token (or a threaded reply to a delivered Inbox message) is an **Inbox answer** —
it resolves that item and is **consumed** (never broadcast). Any other inbound message is a
**channel message** — fanned out to every subscribed session. This is the existing
`reply_resolver`-tried-before-handler pattern (`connectors/gateway.py`), generalized.

## Sessions are durable — they never "end" (decided with Rohit 2026-06-27)

A session is **not** tied to a socket, a process, or a turn. It lives in the conversation store and
is **always resumable** — a user message, a self-wake, an Inbox resolution, or a channel message can
wake any session at any time (busy → steer, idle → a fresh background turn; **no live socket
required**). So:

- **A session never fully dies.** The only thing that removes a session is the **user explicitly
  deleting it**. "The GUI is closed" / "the socket dropped" / "the process restarted" do not end a
  session — they just mean the next interaction resumes it from the persisted thread.
- This is why durable identifiers bind to **`session_id`**: it's a stable handle that always
  resolves to a resumable session. (It's also the deeper reason the parked-Inbox-prompt model works
  — see `PERMISSIONS-AND-INBOX.md`: a prompt belongs to the session, not the connection.)
- Practical consequence for subscriptions (below): a `(session_id, channel)` subscription is
  **permanent** until explicit teardown; it isn't "lost" when the session goes idle or the server
  restarts. Deleting the session is the one implicit unsubscribe.

## Channel subscription (inbound pub/sub) — **v1 built 2026-06-28**

**Subscription ≠ Inbox routing.** Routing is *outbound* (mirror an agent's approvals/questions OUT
to a DM/channel; request↔reply, `[ocw:id]`-correlated). A subscription is *inbound* (bring a
channel's messages IN; broadcast). Keep them on different channels — pointing your Inbox at a
channel you also subscribe to conflates the two directions (a guard warns when they collide).

- **Subscription** = a persisted `(session_id, channel)` record (`channel` = the address
  `"<platform>:<chat_id>"`). Many sessions may subscribe to one channel (two agents, two reactions).
  **Permanent until the user or the agent unsubscribes** (or the session is deleted). Survives
  restarts (see "Sessions are durable").
- **Created by the agent via a tool** (`subscribe_channel` / `unsubscribe_channel` /
  `list_subscriptions`), registered for messaging personas. The agent **bootstraps by asking the
  user which channel** with `ask_user` — it doesn't autonomously know channels. (Slack encodes a
  typed `#alerts` as `<#C0123|name>`, so `subscribe_channel` parses the id straight out of the
  answer; a raw address works too. A GUI channel picker is a later nicety.)
- **Delivery** of a (non-token) channel message → every subscribed session, via the self-wake path:
  **busy → `queue_steering`; idle → a fresh background turn.** Each agent decides from its **own
  role/prompt** whether the message is relevant — that judgment *is* the per-agent routing. A
  channel with **no** subscribers is buffered but delivered to no one; a **DM** with no subscription
  falls through to the default super-agent session.
- **Catch-up tool** `get_channel_messages(channel, n)` — last *n* messages from an in-memory ring
  buffer filled as messages arrive (so an agent can subscribe, then ask "what did I miss?").
- **Reply** via the existing `send_message(target, text)` tool, targeting the channel address
  carried in the delivered message.
- **Loop prevention is free:** the Slack adapter already drops the bot's own messages
  (`bot_id`/`subtype`/`user==bot_user_id`). With one bot identity, *every* agent's channel post is
  "the bot," so agent-↔-agent and self-loops are filtered at the source.

**v1 filter:** a subscribed channel delivers **all** its (non-bot, non-token) messages — the
subscription *is* the filter. "Mentions + their threads only" is a later refinement: the inbound
`MessageEvent` doesn't yet carry "was the bot @mentioned" (the bot's user id is known, so it's a
clean adapter add later).

### The Slack identity constraint (why "@mention per agent" doesn't work natively)

OpenCoworker connects as **one bot user** (one token = one Slack identity). A native `@mention`
resolves to that single bot — so you **cannot** natively `@mention` "Ops" vs "Research" as
separate Slack users without giving each persona its own Slack app install (heavy; kills
two-click subscribe). So "mention" operates at **two levels**:

1. **Bot-level mention = the activation filter (native, one identity).** A message that
   `@OpenCoworker`s the bot, or is a **DM**, or is in a **thread** the bot is in, is what marks it
   "for me" vs. channel chatter. This is the noise/cost filter — *not* per-agent routing.
2. **Subscription + the agent's role = the fine routing.** A bot-mention on a channel is
   delivered to **all** subscribed sessions; each judges relevance from its own prompt. No
   per-agent identity needed.
3. **Optional explicit targeting = a text convention, not a Slack mention.** e.g.
   `@OpenCoworker ops: …`, parsed from text against a subscription's declared keyword. Nice-to-have.

**Default filter:** deliver only bot-mentions / DMs / in-thread replies (so idle agents aren't
woken by every line on a busy channel). A subscription may opt into "all messages" for a
dedicated channel. **A DM with no channel context → the user's default persona session** (the old
super-agent role).

**Per-persona Slack identities** ("@OpsCoworker" as a distinct teammate) are nicer UX but a
**multi-install managed-connector story** — parked as a future/premium path, not v1.

## Mirroring the Inbox to a channel — **interactive buttons** (built 2026-06-28)

How does the user *answer* an Inbox prompt from Slack? Not by replying with `[ocw:id]` in the text
(Slack does nothing to keep the token in a reply — a bare "yes" loses it; that path is brittle).
Instead the mirrored item is a **Block Kit card with buttons**, and **the item id rides inside each
button's `value`** (`{"id":…, "r":"allow"}`). A click sends an interaction payload that names the
exact item + choice — unambiguous, no token, no thread tracking.

- **Discrete choices → buttons:** approvals (`Approve`/`Deny`), `ask_user` **options** (one button
  each). `interactions.buttons_for(item)` builds them; `encode`/`decode` own the value.
- **Free-text answers are NOT offered over messaging** (decided with Rohit): that prompt shows
  "*open the app to respond*" — the user types in the App. (The `[ocw:id]` token stays only as a
  legacy reply fallback for those.)
- **Inbound:** socket mode delivers the click over the same connection (no public endpoint — just
  "Interactivity" enabled in the Slack app). `SlackAdapter` `@app.action("ocw_*")` → gateway →
  `manager._on_interaction` → `inbox.resolve(id, r)` → the buttons are swapped for the outcome
  ("✅ Approved by @you"). Resolving releases any suspended agent (first-responder-wins with the app).
- **Provider-agnostic:** a `Button(label, value)` the adapter renders natively; Telegram inline
  keyboards + plan/directory buttons are follow-ups. v1 = single-select, Slack.

This largely **retires the brittle token-reply path** for the common case — buttons carry the id.

## Inbox correlation (targeted request/reply)

The Inbox item **id is the correlation key**; a button's value (or, legacy, the `[ocw:id]` token)
carries it.

**1. Live correlation — already works.** The asking session's engine is suspended mid-tool-loop
at `await store.wait(item.id)`. Resolving by id fires *exactly that await*, so "right session +
right tool call" is handled **structurally** — the answer returns to the precise point in the
precise engine. No extra bookkeeping while the process is alive.

**2. Durable correlation — the hard part (not yet built).** If the server restarts or the session
is evicted before the answer arrives, the in-memory `await` is **gone**. To survive that, the
Inbox item must persist enough to *reconstruct* the suspension:
- `session_id` + **`tool_call_id`** (+ tool name/args).
- On resume: rebuild the session engine and **inject the answer as the tool result for that
  `tool_call_id`**, then continue the turn (rather than re-calling the tool).

Today items hold `session_id` but **not** `tool_call_id`, and resolution only fires a live waiter.

**3. `ask_user` tool — the general Q&A primitive (new).** Approvals (allow/deny) already ride the
permission/approver path. Free-text Q&A ("which region?") needs a first-class tool the agent
calls that creates a `KIND_QUESTION` item, blocks, and returns the answer string — generalizing
the approver. It is the natural owner of the `(session_id, tool_call_id)` capture.

**4. Approvals have no tool-level id** — so the **Inbox item id is the generated correlation
handle** for them (we also capture the engine's tool_call where one exists).

## Relationship to what's already built

- `connectors/gateway.py` — `reply_resolver` (consumes `[ocw:<id>]` → `resolve_from_reply`),
  allowlist, inbound dispatch. Channel subscription extends this: non-token → fan out to subscribers.
- `inbox_routing.py` — named inbox = queue + binding (Slack/Telegram); `deliver` embeds `[ocw:<id>]`.
  Subscription is the **inbound** counterpart of the **outbound** binding.
- `inbox.py` — `InboxStore` + state machine + `wait`/`resolve`. Needs: `tool_call_id` on items;
  `ask_user` path; (later) durable resume.
- Self-wake busy→steer / idle→new-turn (`manager.resume_due_wakes`) — reused verbatim for inbound
  channel delivery.

## Decisions settled (2026-06-27)

- Two mechanisms: **channel subscription** (pub/sub, no correlation) vs. **Inbox** (request/reply,
  correlated); the `[ocw:<id>]` token discriminates inbound on a shared channel.
- Channel subscription = `(session_id, channel, filter?)`; many sessions per channel; delivery via
  busy→steer / idle→new-turn; `get_channel_messages` catch-up tool.
- **One bot identity.** Bot-mention/DM/thread = activation filter (default); subscription + agent
  role = routing; optional text-keyword = explicit targeting; **per-persona Slack identities =
  future path**. DM with no channel → default persona session.
- Inbox: item id = correlation key; **`ask_user` tool added** as the general Q&A primitive;
  approvals use the item id as their correlation handle.
- Durable resume: **best-effort live-only for v1** (a restart-orphaned question is re-surfaced for
  the agent to re-ask); **hardened replay-as-tool-result** (persist `tool_call_id`, inject as tool
  result on resume) is a Phase-2 follow-up.

## Decisions settled (2026-06-27/28 — channel subscription build)

- **Sessions are durable; they never end.** A session is always resumable (no socket/process
  dependency); **only explicit user deletion** removes one. Durable bindings use `session_id`.
- **Subscription is INBOUND; Inbox routing is OUTBOUND** — orthogonal directions; a guard warns when
  a subscription and a routing target collide on the same channel.
- **Subscription = persisted `(session_id, channel)`, permanent** until explicit unsubscribe (agent
  tool or user) or session deletion (the one implicit teardown). Survives restarts.
- **Agent-tool creation** (`subscribe_channel`/`unsubscribe_channel`/`list_subscriptions`); the
  agent **bootstraps via `ask_user`** to learn the channel from the user. Slack `#channel` mention
  tokens (`<#id|name>`) are parsed for the id; no name→id API lookup in v1.
- **v1 filter = the subscription itself** (deliver all of a subscribed channel's non-bot messages).
  Mention/thread filtering deferred (needs the adapter to surface `@bot`; the bot's user id is
  known, so it's a clean later add).
- **Loop prevention is already handled** by the adapter (drops bot-self messages); one bot identity
  makes agent-↔-agent loops impossible too.
- **`get_channel_messages`** = an in-memory ring buffer (last N/channel), not the Slack history API.
- Delivery reuses `manager.deliver_to_session` (busy→steer / idle→background turn), shared with
  self-wake. Reply via `send_message` to the channel address.
- **Answering an Inbox prompt from Slack = buttons, not free-text replies.** The item id rides in
  each button's value (Block Kit; socket-mode action callback → `inbox.resolve`), so correlation is
  unambiguous and the brittle `[ocw:id]`-in-reply path is mostly retired. **Free text isn't offered
  over messaging — the user opens the app** (token kept only as a legacy fallback). v1 = single
  select, approvals + ask_user options, Slack; Telegram inline keyboards + plan/directory = later.

## Open questions / follow-ups

- ✅ Subscription persistence — done (`subscriptions.json`, `SubscriptionStore`). **GUI** to view /
  manage subscriptions per session is still to build (v1 is agent-tool-driven).
- Authorization: subscription is a per-session opt-in on top of the gateway allowlist — confirm the
  trust model (who can subscribe a session to which channels). v1 lets the agent self-subscribe.
- Mention/thread filtering: surface `@bot` in the inbound `MessageEvent` (Slack adapter has
  `bot_user_id`), then let a subscription opt into "mentions only."
- Telegram analog of "@mention / thread" (groups vs. DMs) — map the same filter concept.
- Channel name→id resolution / a channel picker (so the user can say "#alerts" in the GUI, not only
  in Slack where the id is encoded).

---

### Openworker Archive Docs PERMISSIONS AND INBOX (`openworker-archive/docs/PERMISSIONS-AND-INBOX.md`)

# Permissions, Tool Catalog & Inbox — design

**STATUS: design (draft for review).** Discussed with Rohit 2026-06-26/27. Companion to
`PERSONAS.md` (which references this for its tool/permission/inbox machinery). Covers: the
vetted tool catalog, the risk-class taxonomy, user-local risk overrides, permission modes,
the **Unattended** toggle, and the **Inbox** (incl. multi-inbox routing + resume
reconciliation). Decisions log at the bottom.

## The pieces and how they compose

Five layers, each a clean input to one decision:

1. **Tool catalog** — stable `id → capability`, what a persona references.
2. **Risk class** — intrinsic side-effect category of each tool (`read` / `write_local` /
   `exec` / `external`). Replaces today's hardcoded name sets.
3. **Effective-risk override** — *user-local only* reclassification, mainly to relax MCP's
   conservative default. Never carried by a persona.
4. **Permission mode** — *how autonomous*: Plan / Interactive / Custom / Auto (today's
   `permissions.py`). The ceiling.
5. **Unattended toggle** — *where you're reached*: inline composer vs. **Inbox**, and lets the
   agent suspend/resume instead of blocking on an absent human.

The gate is one function: `evaluate(tool, args, mode, attended?)` → allow / deny / route. Risk
class (after override) decides what's *consequential*; mode decides whether consequential
needs you; the Unattended toggle decides *where* "needs you" lands.

## 1 · Tool catalog

Today tools are plain callables assembled by hand in each agent's `build_tools(context)`
(`agents/code.py` imports the per-tool factories from `tools/` — `files`, `git`, `search`,
`shell`, `todo` (plus `directories`, `plan`, `subagent` on main) — and aisuite `files`/`git`
toolkits, and concatenates them). `tools/registry.py` is runtime plumbing (callable → JSON
schema + exec), keyed by `__name__`. There is no ID→capability layer a manifest can reference
— the agent code *is* the catalog.

Introduce `catalog.py`: each existing factory registered as a **capability**.

```
Capability(
  id          = "files",                 # stable; what a persona's `tools:` lists
  name        = "Files",                 # human label (consent screen)
  description = "Read & edit files in the workspace",
  factory     = <context -> [callables]>,# the existing factory, unchanged
  requires    = ["workspace"],           # context deps: workspace | executor
                                         #               | connector:<x> | secret:<y>
  risk        = { "read_file": "read", "write_file": "write_local", ... },
)
```

`build_tools` then **expands a persona's `tools:` list against the catalog given the session
context** instead of hardcoding imports. Code/Cowork's current hardcoded lists become two
manifests (`tools: [files, git, shell, search, todo]`). Mechanical refactor — factories don't
change.

**Grain:** manifests reference **capability groups** (`files`, `shell`), not individual
function names. Per-function gating still happens via risk class. (Read-only-files is handled
by Plan mode, so no separate qualifier needed in v1.)

**Closed set:** the catalog is **platform-owned only**. Third parties get breadth from us
adding vetted tools + from MCP — never by adding catalog entries. That closed set *is* the
"vetted" guarantee.

**Connector-provided tools:** declaring a connector in a persona (`connectors: [pagerduty]`)
**implies its tool bundle** — you don't list them again under `tools:`. A catalog entry can
declare `requires: [connector:pagerduty]`, so at enable time we tell the user "needs PagerDuty
connected" + offer setup.

## 2 · Risk classes (replaces the hardcoded sets)

`permissions.py` currently hardcodes `WRITE_TOOLS = {write_file, replace_in_file, apply_patch,
apply_unified_diff}`, `SHELL_TOOL = "run_shell"`, plus a per-tool `metadata.requires_approval`.
Formalize that into a declared **risk class** per tool:

| Class | Examples | Behavior |
|---|---|---|
| `read` | `grep`, `read_file`, `git_log`, `web_search` | always allowed |
| `write_local` | `write_file`, `apply_patch`, `replace_in_file` | path-scoped + mode-gated |
| `exec` | `run_shell` | highest local risk; mode-gated |
| `external` | `send_message`, HTTP POST, connector writes, `create_automation` | side effects **off the machine** — the unattended Inbox-routing hook |

The engine reads the declared class instead of matching names. This is a strict improvement
even ignoring personas. `external` is the precise hook for "an unattended agent must queue
this to the Inbox."

Optional **floor flag** on a few genuinely irreversible vetted tools: `never_unattended_auto`
— even a broad posture can't auto-fire them without an explicit extra step.

## 3 · Effective-risk overrides (user-local)

`effective_risk(tool) = user_override ?? catalog_default`.

**The inviolable rule: overrides are user-authored and machine-local. A persona/package can
NEVER carry one.** Otherwise a malicious persona downgrades its own tools past the gate and
declares itself trusted — the whole attack. The persona declares *what it wants*; only the
user decides *how much to trust it*.

Primarily for **MCP**, whose tools we can't vet and therefore default to `external`. Match by
**server + tool-name glob, most-specific wins**:

```toml
[mcp.notion]
default = "read"            # I trust this server; treat its tools as read
[mcp.notion.tools]
"create_*" = "external"     # …except writes
"delete_*" = "external"
[mcp.github]
"*" = "external"            # left conservative
```

Both directions allowed; **upgrade** (`read→external`) is always safe and cheap, **downgrade**
is the deliberate act. Same mechanism *can* reclassify vetted built-ins, but those are vetted
so that's "advanced, at your own risk"; MCP is where overrides are the expected path.

**Primary authoring path is the approval UI, not the file.** When an MCP tool surfaces, offer
*"Always allow," "Trust read-class tools from `notion`."* That writes the same local store the
TOML represents. The file is the portable/power-user form.

## 4 · Permission modes (existing, recapped)

`Mode` ∈ Discuss / Plan (both read-only — `READ_ONLY_MODES`; Plan additionally drives toward a
`propose_plan` approval) · Interactive (auto reads, ask on consequential — default) · Custom
(interactive + auto-allow a configured set) · Auto (full access, still path-scoped). These set
the **autonomy ceiling**. Generalize Custom's `auto_allow_tools` to be **risk-class-aware**
("auto-allow all `write_local`"), not just an enumerated list.

We deliberately **dropped** an earlier proposal for a separate per-class "autonomy grant"
policy layer — the Unattended toggle (next) replaces it. Net simpler.

## 5 · Unattended mode

A **per-session toggle** in the composer area. It does **not** change the autonomy ceiling
(mode does) — it changes *where the human is reached* and lets the agent **suspend/resume**:

- Anything that would prompt inline — approval, question, notification — **routes to the
  Inbox** instead.
- The **composer is disabled** with: *"Running unattended in {mode} — questions & approvals go
  to {Inbox}. Toggle off to take back control."*
- Turning it on is a **one-tap confirm** — *"Run unattended in {mode} → {Inbox}?"* — because
  this is the moment a human hands over control; it deserves one beat of friction.

Because Unattended grants no extra power, it's **safe by construction**: Interactive +
Unattended means "approve everything, just from my phone."

**Mode × Unattended:**

| Mode | Unattended behavior |
|---|---|
| Auto | runs fully autonomously; Inbox gets only notifications / "stuck" / "done" |
| Interactive | every consequential action parks in the Inbox; agent suspends until answered |
| **Custom** | **the overnight sweet spot** — auto-allow safe `write_local`/tests, route only `external` (push/deploy/send) to the Inbox |

## 6 · The Inbox

The canonical, cross-session **human-attention queue** (see `PERSONAS.md`). Three item kinds,
each **deep-linking to its originating session**:

- **Approval** — actionable allow/deny with context.
- **Question** — free-text answer the agent needs to continue.
- **Notification** — FYI / completion (links to artifact).

The Inbox is the **store of record**; messaging connectors + a future mobile app are
**transports** of the same items.

### Sidebar indicators are *views* of this queue (not a second list)

The left-panel "needs attention" signals are **the same Inbox items**, surfaced as breadcrumbs
in the sidebar — there is **no separate "Needs attention" tab** (that would just rebuild the
Inbox and immediately drift). The **attention** signal = *an Inbox item is pending for a
session*, rendered as an **amber count that bubbles up three levels**:

- **session row** → a badge on the exact session waiting on you (click → go there, answer inline),
- **persona accordion header** → a **rolled-up count** so a *collapsed* persona still says
  "something inside needs you" without expanding,
- **footer Inbox** → the **total** across everything (the triage-all view).

All three resolve via the state machine below — answering on the session row, in the Inbox, or
over Slack resolves the one item everywhere. Distinct from this is **liveness** (a session is
*working* or *sleeping with a pending wake*): a **count-less** dot only, which **never** bubbles
into the attention count (so idle scheduled agents don't inflate "needs you"). Attention and
liveness are orthogonal, and both are orthogonal to pinning (see `PERSONAS.md`).

### Item state machine (the anti-race contract)

Each item has **one authoritative state**: `pending → resolved`, resolution recorded **once**,
**idempotent, first-responder-wins**. The agent only ever consumes the first resolution; a
second attempt from any surface is a no-op that shows "already answered." Every surface
(in-app, Slack, composer) reflects the resolved state (e.g. the Slack message edits to
"✅ approved by you"). This is what makes multi-surface answering safe.

### Resume reconciliation (surface answers back to the composer)

When a user **toggles Unattended off** (resumes attended), the session must reconcile so there
is a single coherent place going forward and **no double-answer races**:

- **Pending** Inbox items for that session surface **inline in the composer area**, so the user
  answers in one place from now on. (They remain answerable from a mirrored channel too — but
  per the item state machine, answering anywhere resolves the one item.)
- **Answered-while-away** items surface as an inline **recap** — *"While you were away:
  approved deploy to staging, answered 2 questions"* — so the session reads coherently and the
  user sees what was decided.
- The composer re-enables; future prompts come inline again; in-flight items stay answerable
  from either place but resolve once.

## 7 · Multi-inbox routing

An **Inbox = a named queue + delivery binding(s)**:

- **In-app is always present** (the canonical store).
- Optionally **mirrored** to a Slack channel / Telegram chat via the messaging connector.
- **Per-persona default + per-session override.** Ops sessions → `#ops-coworker`; personal
  helper → a Telegram DM; a SWE session → in-app only.

Bindings are **bidirectional**: out = the approval/question; in = the reply/approval,
**correlated by item ID** so a "✅ approve" in Slack resolves the right pending action and
wakes the right suspended agent. This reuses the existing connector gateway (inbound dispatch
+ outbound `send_message` + steering) — Inbox routing is mostly wiring those to a queue with
the item state machine in the middle.

## Relationship to existing code

- `permissions.py` — `Mode` enum + `evaluate()` stay; replace `WRITE_TOOLS`/`SHELL_TOOL`/
  `requires_approval` with declared risk classes read from the catalog; add the `attended?`
  axis (route vs. ask); generalize Custom's `auto_allow_tools` to risk classes.
- `tools/registry.py` — stays runtime plumbing; the new `catalog.py` sits above it (IDs,
  factories, `requires`, risk).
- `connectors/` gateway — already has inbound dispatch + `send_message` + steering; the Inbox
  bindings build on it.
- `automation/` scheduler / `TaskStore` — shared by self-wake (suspend/resume) per
  `PERSONAS.md`.

## Open questions

- Risk-class-aware Custom: config grammar for "auto-allow class X."
- Inbox persistence/dedup, read/unread, stale-item expiry.
- Whether resume-recap is ephemeral (a system note) or a real turn in the transcript.
- Floor list (`never_unattended_auto`) — which vetted tools, if any, at launch.
- Wake budgets / runaway detection — still tabled (see `PERSONAS.md`).

## Decisions log

- **2026-06-26** — Vetted tool catalog: platform-owned, closed set; personas reference
  capability IDs; connectors imply their tools; MCP is *not* in the catalog.
- **2026-06-26** — Risk classes `read`/`write_local`/`exec`/`external` replace hardcoded name
  sets; `external` is the unattended Inbox-routing hook.
- **2026-06-26** — `effective_risk = user_override ?? catalog_default`; overrides **user-local
  only, never carried by a persona**; match by server+glob; approval UI is the primary author
  path; downgrade is the deliberate direction.
- **2026-06-27** — Dropped the separate per-class autonomy-grant layer in favor of the
  Unattended toggle.
- **2026-06-27** — **Unattended** = a per-session toggle that reroutes all agent→user
  interaction to the Inbox and enables suspend/resume; does **not** change the autonomy
  ceiling (mode does); **one-tap confirm** to turn on; composer disabled while on. Custom +
  Unattended is the recommended overnight combo.
- **2026-06-27** — Inbox items have one authoritative state (`pending→resolved`, idempotent,
  first-responder-wins); every surface reflects it.
- **2026-06-27** — **Resume reconciliation**: toggling Unattended off surfaces pending items
  inline in the composer + an inline recap of answered-while-away items — single source of
  truth, no double-answer races.
- **2026-06-27** — Inbox = named queue + delivery binding(s); in-app always + optional
  Slack/Telegram mirror; per-persona default + per-session override; bidirectional,
  item-ID-correlated; reuses the connector gateway.
- **2026-06-27** (UX pass) — Sidebar "needs attention" = **views of the Inbox queue**, not a new
  tab: **attention** (Inbox-pending) bubbles as an amber **count** session→persona-header→footer
  Inbox; **liveness** (working/sleeping) is a separate **count-less** dot that never bubbles.
  Both orthogonal to pinning.

---

### Openworker Archive Docs PERSONAS (`openworker-archive/docs/PERSONAS.md`)

# Coworker Personas — design

**STATUS: design (draft for review).** Discussed with Rohit 2026-06-26. Generalizes the
fixed surfaces of `SURFACES.md` into declarative, distributable **personas**. Supersedes
the four-fixed-surfaces model: **Code becomes a built-in persona, Cowork becomes the
default persona, and MyHelper goes away** — replaced by pinnable long-running persona
sessions. Decisions log at the bottom.

## Why

Today the product ships a fixed set of hardcoded agents (Chat / Code / Cowork / MyHelper).
We want **specialized coworkers** — a SWE Coworker, an Ops Coworker, later Marketing and
Sales — and we want third parties to distribute their own (internal or external) without
forking the app.

The key realization: a "persona" needs **no new primitive**. It is a *packaging and
configuration layer* over things we already have — a system prompt, a tool allowlist,
skills, connectors/MCP, a default permission mode, a recommended model. So a persona is a
**declarative bundle**, and our own built-ins are expressed in the *same* format third
parties use. If SWE Coworker can't be written as a persona manifest, the format isn't good
enough yet (the dogfooding test).

## The model: three orthogonal concepts

Pulling these apart keeps the data model and UI clean — one mechanism covers all three.

| Concept | What it is | Notes |
|---|---|---|
| **Persona** | a *definition / template* (the manifest) | lifecycle: **installed → enabled → surfaced** |
| **Session** | a *running instance*, born from exactly one persona | **pinnable** + **renameable**; identity fixed at birth |
| **View** | how sessions are *grouped* in the sidebar | per-persona **or** flat under Coworker — pure presentation |

- A **session is born from exactly one persona.** It can pull in skills mid-flight, but its
  identity (prompt, tool surface, defaults) is fixed.
- **Pinning is purely an accessibility affordance** — a bookmark. It keeps a session one click
  away (in the sidebar's Pinned band) and has **no behavioral meaning**: it grants no powers,
  changes no permissions, and does not make a session "run." See *No "always-on" mode* below.
- **View** is just a grouping toggle; every session already knows its persona, so nothing in
  the data model changes.

## Family — the workspace model (and the first-class "specialness")

Personas are **not flat**. Every persona belongs to a **family**, and the family is what carries
a persona's structural specialness. Critically, **family encodes the *workspace model*, not the
job role** — and it is **binary**:

| Family | Workspace model | Starts with | UI consequences |
|---|---|---|---|
| **`code`** | works *inside a repo you pick* (git-bound) | a chosen repo, **writable** | Projects grouping, git/diff tools, explorer subagents |
| **`knowledge`** | *produces deliverables* in a scratch folder | an **auto-provisioned writable scratch** (no repo needed) | no Projects; outcome-oriented; low barrier to begin |

**Roles compose on top of a family** — a role = family + tools + connectors + prompt:
- **DevOps** = `code` family (lives in IaC/repos) + shell + cloud/k8s connectors + ops prompt.
- **SecOps** = `code` family (scans repos) + security tools + alerting connectors + a read-leaning prompt.
- **Ops / Marketing / Sales** = `knowledge` family + role tools/connectors/prompt.

So we do **not** add families per role (`devops`, `secops`, …). Family stays binary; everything
role-specific is composition. **Code stays a persona** — the canonical `code`-family persona —
which is exactly what lets a third party ship a DevOps/SecOps/Rust coworker that inherits all the
code-family behavior for free.

**Folders make everything else possible (power users).** Family sets the *starting point and
defaults*, not a hard limit:
- A **knowledge** persona can **add a repo/dir to read** (default read-only) or a dir to **write**
  results into. It already has a writable scratch, so it rarely needs more to begin.
- A **code** persona can **add read-only reference repos/dirs**, or a second **writable** repo for
  cross-repo work.
- **Per-folder writability is a user choice in both families**; family only chooses the sensible
  default/emphasis. (This is already the backend model: writable scratch + `extra_roots` with
  per-root `writable` + the `request_directory` grant flow for knowledge; a single writable repo
  root for code.)

**The frontend must be family-aware, not id-aware.** "New Project"/grouping, the add-folder
defaults, git affordances — all key off `family === "code"`, **never** a hardcoded `"code"` id.
(Today the sidebar hardcodes the id; that's the bug behind "why does only Code have Projects.")

## What a persona is (the bundle)

A persona manifest is **skill-shaped markdown with richer YAML frontmatter** — `persona ⊇
skill`, so authoring is familiar. The markdown body *is* the system prompt; the frontmatter
declares the capability surface.

```yaml
---
id: ops-coworker
name: Ops Coworker
icon: ops          # from the built-in icon set
tagline: Watches your infra and runs the runbook
tools: [shell, files, web_search, http]   # vetted-catalog IDs (allowlist)
connectors: [pagerduty, datadog]          # expected integrations
mcp: []                                    # MCP servers it wants
recommended_models: [anthropic:claude-opus-4-8, openai:gpt-5.5]
skills: [incident-runbook, postmortem-writer]  # referenced skill IDs
default_permission_mode: read-only         # cannot self-grant higher
family: knowledge                          # code | knowledge — the workspace model (see Family)
workspace: deliverable                     # git | deliverable | none
automations: []                            # optional starter schedules
---
You are the Ops Coworker. Your job is to ... (operating doctrine)
```

- **Tools are referenced by ID** from a vetted catalog (see Distribution). A persona never
  ships executable code.
- **`recommended_models` are recommendations, not requirements.** If the user runs a
  different / unconfigured model, that's their choice — at the risk of worse results. We
  surface a gentle nudge (reusing the existing `model_ready` machinery), never a hard block.
- **`default_permission_mode`** is the *starting posture*. A persona cannot self-escalate; a
  higher posture requires explicit user opt-in at enable time.

### Lifecycle states

1. **Installed** — present on disk (built-in, local dir, or fetched from a git URL).
2. **Enabled** — the user turned it on in the Personas settings panel.
3. **Surfaced** — the user chose to show it as a selector when starting a new Coworker
   session. (Enabled-but-not-surfaced personas still run; they just don't clutter the
   new-session picker.)

**Cowork is the default persona and is always present**, but can be disabled for users who
want only specific personas. **If Cowork is disabled, the new-session button defaults to the
user's chosen default persona** (set in the same panel) — so there's never a dead end.

### The collapse (replaces SURFACES.md)

- **Code → a built-in `code`-family persona** (git-bound workspace, coding tools).
- **Cowork → the default `knowledge`-family persona** (deliverable workspace, general tools).
- **MyHelper → gone.** Its "personal helper" role becomes *a pinned, renamed persona session*.
  The win: any persona session can be triggerable (self-wake / messages / events), and there's
  no special fourth subsystem to maintain. There is no "always-on" mode (see above).

## Sidebar information architecture

Decided with Rohit (UX pass, 2026-06-27). Layout = **"Pinned band on top"**:

```
✦ OpenCoworker                       ⚙
🔍 Search   🔌 Integrations   ⏰ Automations   ← shared top zone (global, all coworkers)
── PINNED ──────────────────────────
📌 Ops Watcher              ·  (liveness dot)
📌 Daily Brief            ②   (attention count)
── SURFACES ─────────────────────────
▾ ⬥ OpenCoworker      ★default
    Q3 research memo    ●  (attention on this session)
    + New session
▸ ⬦ Ops Coworker      ①   (rolled-up attention; collapsed)
▸ ◧ Code        › Projects
─────────────────────────────────────
📥 Inbox ②   🗒 Audit   ⚙ Manage
```

Rules:
- **Shared top zone** above the persona accordions for things common to *all* coworkers:
  **Search**, **Integrations** (the global connect page), **Automations**. (Previously these
  lived inside the Cowork accordion only, so Code/Ops couldn't reach them — a real gap.) The
  footer keeps the meta items: Inbox, Audit, Manage.
- **Default persona (OpenCoworker) leads** the persona list; **Chat is hidden by default**
  (not-surfaced — recoverable from the Personas tab, not deleted; Cowork covers quick Q&A).
- **A cross-persona "Pinned" band sits at the top.** It contains **only manual pins** (pure
  bookmarks). Nothing auto-surfaces into it.
- **Expand ≠ switch.** Expanding a persona accordion **browses** its sessions; the chat area
  **only changes when you actually pick a session or hit New session** — so you can peek at
  another persona's sessions while keeping your current conversation loaded.
- **Projects nest only under `code`-family personas** (family-aware, not id-aware).

### Attention vs. liveness (sidebar indicators)

Two **orthogonal** per-session signals, neither tied to pinning (any session can show either):
- **Attention** = an **Inbox item is pending** for that session. Rendered as an **amber count**
  that **bubbles up**: session row → persona accordion header (rolled-up count) → footer
  **Inbox** total. All three are *views of the one Inbox queue* — answering anywhere resolves it
  everywhere (the resolve-once, first-responder-wins state machine in `PERMISSIONS-AND-INBOX.md`).
  There is **no separate "Needs attention" tab** — that would just rebuild the Inbox.
- **Liveness** = "working now" or "sleeping with a pending wake." A **quiet, count-less dot**.
  Informational; it **never bubbles into a count** (so idle scheduled agents don't inflate the
  attention number).

## Distribution

**Hard rule: we never ship or run third-party executable code.** The platform ships a broad
catalog of **vetted tools**; personas compose them by ID. New capabilities arrive via **MCP**
(which has its own boundary), not via persona code. This makes "install a persona" a *light*
trust event — the user consents to a declared set of tools / connectors / permission mode,
not to arbitrary code.

Two distribution modes, different weight:

1. **Persona packages (lightweight, build first).** A folder + manifest, loaded from a
   **local dir** (`~/.coworker/personas/`) or a **git URL**. Runs on stock OpenCoworker. This
   is how "Acme Ops Coworker" reaches people who already run the app. At install we show an
   **install-time capability consent**: "This Coworker will use shell, files, PagerDuty, and
   defaults to read-only mode. Allow?"
2. **White-label builds (heavy, later).** A company configures/brands the app, bakes in its
   default personas, and ships it as *their own* coworker product.

The **vetted-tool catalog** (how tools are namespaced/grouped so frontmatter can reference
them cleanly) is designed in `PERMISSIONS-AND-INBOX.md`.

## No "always-on" mode — being triggerable is a consequence, not a state

There is **no "always-on" agent class.** Every session is equally "on": it can resume whenever
*something can trigger it* — a self-wake (`sleep_*`), an inbound message, or (later) an event.
Being triggerable is simply a **consequence of the tools/connectors that session holds**, not a
mode you switch into. A plain Cowork session that called `sleep_for` is exactly as "always-on" as
a dedicated Ops watcher. This is why **pinning is pure accessibility** (above) and never a
behavioral toggle — there's no behavior to toggle.

## Long-running agents — self-wake tools

A long-running agent is not a process that's *always running* (that was MyHelper's cost
problem). Instead it is **suspend/resume — event-driven**: it sleeps at ~zero cost and the
scheduler **re-invokes the session** on a trigger. "Many long-running agents" is cheap precisely
because idle ones aren't running.

Triggers (a small set):

1. **Timer** — `sleep_for(duration)` / `sleep_until(time)`.
2. **On-completion** — `wake_on(job_id)` when a backgrounded command/job exits; the exit code
   and output are handed back on resume.
3. **On-message (free)** — a user steering message into the session wakes it (reuses the
   inbound queue/steering of the messaging connectors).
4. *(Later)* **On-event** — a connector/webhook fires (an email arrives, a PR opens, an alert
   triggers). Same plumbing; this is what makes a pinned Ops Coworker genuinely useful.

**Relationship to Automations** (`AUTOMATION-SCHEDULING.md`): different surfaces over the
**same scheduler / TaskStore**. An Automation is *user-authored* — a cron that **spawns** a
session. Self-wake is *agent-authored* — a live session scheduling **its own** resumption.
Both reduce to "enqueue a future invocation of a session," so they share plumbing but stay
distinct in the UI.

## The Inbox

> Full design — risk classes, modes, the **Unattended** toggle, item state machine, resume
> reconciliation, and multi-inbox routing — lives in `PERMISSIONS-AND-INBOX.md`. Summary below.

The **Inbox** is the canonical, cross-session **human-attention queue**. While you're working
in one session, it tells you another agent needs you — an approval, a question, or a result.
It decouples agent progress from your attention: agents keep working (or sleep) and route the
human-needed items to **one place**.

Three item kinds, each **deep-linking back to its originating session**:

- **Approval** — actionable allow/deny with context ("Ops Coworker wants to restart the
  service").
- **Question** — a free-text answer the agent needs to continue.
- **Notification** — FYI / completion ("your report is ready" → links to the artifact).

**The Inbox is the store of record; messaging connectors and a future mobile app are
*transports* of the same items**, not separate notification systems. Slack / Telegram / OCW
Mobile render and deliver inbox items to wherever the user is, and replies flow back as
steering. One item model, many delivery channels — matches the existing messaging-connector
design (outbound `send_message` + inbound steering).

**Why this comes before guardrails:** if consequential actions must route to the Inbox rather
than self-execute, *the human becomes the rate-limiter*. An unattended agent can't run away if
every consequential step waits on a person. So the Inbox is also a lightweight substitute for
the heavier safety machinery below.

## Tabled for later — unattended guardrails

Wake budgets / rate caps and runaway-loop detection are **noted but deliberately not designed
yet.** With the Inbox gating consequential actions, the immediate need is lower. Revisit once
truly autonomous (non-gated) unattended work is on the table.

## Phasing

> Live progress + per-phase test gates: `IMPLEMENTATION-LEDGER.md`.

- **Phase 0** — foundation: the vetted tool catalog + risk-class refactor of `permissions.py`
  (no behavior change). Both personas and permissions sit on it. See `PERMISSIONS-AND-INBOX.md`.
- **Phase 1** — persona registry + manifest format; convert Code/Cowork into built-in
  personas; ship 2–3 defaults (SWE, Ops, generic); persona picker at new-session; Personas
  settings panel (enable / surface / set-default). Pinnable + renameable sessions.
- **Phase 2** — load personas from local dir / git URL + install-time capability consent;
  self-wake tools (timer + on-completion); the Inbox (in-app).
- **Phase 3** — connect the Inbox to Slack/Telegram/mobile; on-event wake; registry /
  marketplace; white-label build tooling.
- **Phase 4 (UX/IA)** — family-aware frontend (Projects/folders keyed off `family`, not id);
  "Pinned band on top" sidebar; default-persona-first ordering; Chat hidden by default;
  expand≠switch; attention badges bubbling session→persona→Inbox + a count-less liveness dot.

## Open questions

- **Persona versioning / update** — refresh semantics for git-URL personas; namespacing to
  avoid collisions between two "Ops Coworker"s from different authors.
- Unattended guardrails (tabled, above).
- (Tool catalog, risk classes, Inbox persistence — now in `PERMISSIONS-AND-INBOX.md`.)

## Decisions log

- **2026-06-26** — A persona = declarative skill-shaped bundle; **no third-party executable
  code**; tools referenced from a vetted catalog by ID; new capability via MCP.
- **2026-06-26** — Recommended (not required) models; user may override at their own risk.
- **2026-06-26** — A session is born from **exactly one** persona (identity fixed; may pull
  skills mid-flight).
- **2026-06-26** — Three orthogonal concepts: Persona (installed→enabled→surfaced) / Session
  (pinnable + renameable) / View (per-persona or flat).
- **2026-06-26** — Code → built-in persona; Cowork → default persona (disable-able; if off,
  new-session defaults to the user's chosen default persona); **MyHelper removed**, replaced
  by pinned long-running persona sessions.
- **2026-06-26** — Long-running = suspend/resume via self-wake (timer / on-completion /
  on-message; on-event later); shares the scheduler with Automations but is a distinct,
  agent-authored surface.
- **2026-06-26** — **Inbox** is the canonical human-attention queue (approval / question /
  notification, each deep-linking to its session); messaging connectors + mobile are
  *transports* of the same items; it also serves as the lightweight rate-limiter.
- **2026-06-26** — Wake budgets / runaway detection **tabled** (not yet designed).
- **2026-06-27** (UX pass) — **Family is binary** = the *workspace model* (`code` = git-bound,
  Projects/diffs/explorer; `knowledge` = auto-scratch deliverables). Roles (DevOps/SecOps/Ops/…)
  **compose on top** (family + tools + connectors + prompt); we do **not** add families per role.
  DevOps/SecOps are `code`-family.
- **2026-06-27** — **Code stays a persona** (canonical `code`-family persona); `family` is the
  first-class "specialness". Frontend must be **family-aware, never id-aware** (the `"code"` id
  hardcode is the bug behind "only Code has Projects").
- **2026-06-27** — **Per-folder writability is a user choice in both families**; family only sets
  the default/emphasis. Knowledge can add read/write folders; code can add read-only refs or a
  2nd writable repo. (Already the backend model.)
- **2026-06-27** — **No "always-on" mode.** Every session is equally triggerable as a consequence
  of its tools/connectors (self-wake / message / event). **Pinning = pure accessibility** (a
  bookmark), no behavioral meaning. Supersedes the "pinned = your always-on agent" framing above.
- **2026-06-27** — Sidebar IA = **"Pinned band on top"**: cross-persona Pinned band (manual pins
  only) → default-first persona accordions → footer nav. **Expand≠switch**; **Chat hidden by
  default**; Projects only under `code` family.
- **2026-06-27** — Sidebar **attention** (Inbox-pending) = amber **count** bubbling
  session→persona-header→footer Inbox (views of one queue, not a new tab); **liveness**
  (working/sleeping) = a separate **count-less** dot. Orthogonal to pinning.

---

### Openworker Archive Docs UI REFRESH KICKOFF (`openworker-archive/docs/UI-REFRESH-KICKOFF.md`)

# UI Refresh — Implementation Kickoff (start here)

Entry point for the agent(s) implementing the GUI/messaging refresh. Read this first, then the specs.

## Read in this order
1. [`UX-DECISIONS.md`](UX-DECISIONS.md) — the *why* + the binding UX decisions. **Do not change
   documented UX without the owner's (Rohit's) sign-off.**
2. [`UI-REFRESH-SPEC.md`](UI-REFRESH-SPEC.md) — the *what/how* (full-stack), with build phases 1–5.
3. [`FAKE-SLACK-SPEC.md`](FAKE-SLACK-SPEC.md) — **build this first** (a separate sub-agent); the
   integration tests depend on it.
4. [`UI-REFRESH-VERIFICATION.md`](UI-REFRESH-VERIFICATION.md) — the test cases + acceptance gate.
5. Visual reference: [`../ui-mocks/redesign.html`](../ui-mocks/redesign.html) (open in a browser; top
   switcher: session / integrations / persona).

## Environment & conventions
- **Worktree:** `/Users/rohit/fleet/ro4d/aisuite-personas` · **branch:** `platform/personas`
  (~64 commits ahead of `origin/main`). **Local-only — do NOT push; Rohit pushes.**
- **Commits:** author **and** committer `Devika <dr.drp8226@gmail.com>`, **no co-author trailers**
  (project's explicit choice for this work). Commit per phase; keep PR/commit descriptions short
  (one paragraph; detail in the body only if mechanical).
- **Tests:** `./.runtests.sh platform/tests/<file> -q` (uses the agent-platform venv). Baseline is
  green **except 3 pre-existing SDK-import failures** (`test_anthropic_provider`,
  `test_gemini_provider`, `test_provider_router`) — leave them; don't "fix" by mocking SDKs.
- **Run the server** (manual/visual): `SLACK_API_URL` (FakeSlack) +
  `PYTHONPATH=platform /Users/rohit/fleet/ro4d/aisuite/platform/.venv/bin/python -m coworker.server.run --port 8765`.
  Dev GUI: `cd platform/surfaces/gui && npm run dev` (serves on :1420, defaults to backend 8765).
  Frontend gate: `npx tsc --noEmit` clean + `npm run build`.
- **No backward compatibility** — pre-launch, no users. Migrate on-disk state by deletion if needed.
- **Visual review before PR:** show GUI changes in the running dev app for Rohit's review before
  raising a PR (don't open a PR off green tests alone for UI work).
- **Progress/handoff:** append a one-line entry per working session to
  [`IMPLEMENTATION-LEDGER.md`](IMPLEMENTATION-LEDGER.md) and commit it (that commit is the handoff).
  That ledger is the **shared running log for the whole `platform/personas` branch** — it already
  tracks the (completed) Personas and Messaging streams; the UI Refresh is the current stream. Label
  your entries **"UI-Refresh Phase N"** — the bare "Phase N" in older entries refers to the
  *Personas* effort (its Phases 0–3 are done) and is unrelated to this spec's Phases 1–5.

## Suggested agent topology
- **Agent A (first):** FakeSlack (`FAKE-SLACK-SPEC.md`) + its self-tests + the one adapter
  `SLACK_API_URL` override. Self-contained; unblocks everything else.
- **Then per phase (SPEC §9):** one focused agent per phase (1 registry/contract → 2 structured
  messages → 3 connection hierarchy → 4 persona/session surfaces → 5 frontend polish). Each: code +
  the phase's VERIFICATION tests green, `tsc`/build clean, commit, ledger line. Phase 3 (hierarchy) is
  the load-bearing data-model change — review it before building 4–5 on top.

## Open decisions baked into the spec (change before building if you disagree)
- Structured messages keep *framed* text for the model + a display-only `source` sidecar (stripped
  before the provider).
- Placeholder `available:false` descriptors for not-yet-shipped recommended connectors (GitHub,
  Datadog, …) so the UI renders their badge + Connect.
- Persona default connections seed from manifest `recommends` where `tier:core` AND connected.
- One production change for FakeSlack: a `SLACK_API_URL` base-URL override on the Slack adapter.

---

### Openworker Archive Docs UI REFRESH SPEC (`openworker-archive/docs/UI-REFRESH-SPEC.md`)

# UI Refresh — Implementation Spec

**Status:** ready to implement. **Audience:** the implementing agent (has full repo access).
**Scope:** full-stack (data model → stores → connector contract → gateway → REST/WS → React).
**Backward compatibility:** **none required** — pre-launch, no users. Redesign cleanly; migrate
on-disk state by deletion if needed.

**Companion docs**
- UX rationale + decisions: [`UX-DECISIONS.md`](UX-DECISIONS.md) (the *why*; don't violate it).
- Visual spec: [`../ui-mocks/redesign.html`](../ui-mocks/redesign.html) (the *look*).
- Tests + acceptance: [`UI-REFRESH-VERIFICATION.md`](UI-REFRESH-VERIFICATION.md).
- Test harness: [`FAKE-SLACK-SPEC.md`](FAKE-SLACK-SPEC.md) (separate deliverable — build first; the
  verification suite depends on it).

Throughout, `→` marks a file to change. Verify signatures against current code before editing.

---

## 0. Principles

1. **One connector contract, Slack is the reference.** No Slack special-casing in the core. Every
   connector adapter implements the same interface (§2); Slack-specific mechanics (Socket Mode, Web
   API, Block Kit, name resolution) live behind it. New connectors (Salesforce, HubSpot, …) implement
   the contract and "just work" in the UI via the registry (§1).
2. **Structured, not stringly-typed.** Connector inbound messages become structured records carrying
   identities + display names (§3), so the GUI renders rich cards and the model still gets framed
   text. No parsing of `"💬 New message on slack:C…"` in the frontend.
3. **Connection hierarchy** (UX §4, Decided): `account-connected` → `persona-default-enabled` →
   `session-override`. Effective = connected AND persona-default(unless session override) AND not
   session-disabled (§4).
4. **Names everywhere, ids on hover.** Resolve + cache `id → display name` for users *and* channels
   at the connector boundary (§2.3); persist names with messages.

---

## 1. Connector registry metadata (brand color + logo)

Goal: the UI renders any connector by `logo` id + `brand_color`, with a neutral fallback.

**Backend**
- → `coworker/connectors/descriptors.py`: add to `ConnectorDescriptor`:
  - `brand_color: str = "#6b7280"` (hex; the connector's brand, fallback gray)
  - `logo: str = ""` (a stable logo id, e.g. `"slack"`, `"github"`; empty → fallback)
  - Populate for slack/telegram and the not-yet-shipped ones referenced by personas
    (github, datadog, salesforce, hubspot, pagerduty) — descriptors may exist as **available:false**
    placeholders so recommendations can render. (A placeholder descriptor = id, title, icon, blurb,
    brand_color, logo, `available=False`, `two_way` as appropriate, no `connect`.)
- → `coworker/connectors/setup.py::connector_list`: include `brand_color`, `logo` per connector.

**Frontend**
- → `surfaces/gui/src/connectors/registry.ts` (new): `CONNECTORS: Record<string, {label; logo}>`
  mapping `logo` id → an imported SVG component, plus `FALLBACK`. A `<ConnectorIcon connector=…/>`
  and `<ConnectorBadge/>` component reads `brand_color` from API data and the SVG from the registry;
  unknown id → fallback plug glyph + the provided/neutral color. (Mirror the mock's `CONNECTORS`
  object + `data-brand` tinting.)
- Logos are bundled frontend SVG assets keyed by id; brand color comes from the API so it's one
  source of truth.

---

## 2. Connector contract (the abstraction)

Define the interface every adapter satisfies. Today `BasePlatformAdapter` + `SlackAdapter`/
`TelegramAdapter` (`coworker/connectors/adapters.py`) and `Gateway` (`gateway.py`) already cover most
of this; this section formalizes + extends it.

### 2.1 Interface (adapter)
```
class PlatformAdapter:
    platform: str
    brand: ConnectorBrand            # {color, logo} (mirror descriptor)
    async def connect() -> bool
    async def disconnect() -> None
    async def send(chat_id, text, *, thread_id=None) -> SendResult
    async def send_interactive(chat_id, text, buttons: list[Button]) -> str   # returns message id
    async def update_message(chat_id, message_id, text, *, buttons=None) -> None
    async def resolve_user_name(user_id) -> str | None      # cached (§2.3)
    async def resolve_channel_name(chat_id) -> str | None    # cached (§2.3)
    # inbound: adapter calls gateway handlers with MessageEvent / InteractionEvent
```
Slack already implements `connect/disconnect/send/_display_name`; **add** `send_interactive`,
`update_message` (currently on senders/gateway — keep, but route through the adapter), and
`resolve_channel_name`. Telegram implements the subset it supports (no buttons → `send_interactive`
falls back to text + numbered replies, already partially present).

### 2.2 Inbound event shape
- → `coworker/connectors/base.py`: extend `SessionSource` with `chat_name: Optional[str] = None`
  (channel/DM display name; user_name already exists). `MessageEvent` already carries `text`,
  `source`, `message_id` (the ts). Adapters MUST populate `user_name` + `chat_name` (resolved, §2.3)
  before handing the event to the gateway.
- `_dispatch_inbound` (§3) builds the structured `MessageSource` from these.

### 2.3 Name resolution + cache
- Slack adapter has `_display_name` (users.info, cached). **Add** `_channel_name` (conversations.info,
  cached) with the same pattern; both keyed by id, in-memory `dict`, best-effort (None on failure,
  caller falls back to id). A short TTL is optional (names rarely change) — start with no expiry,
  re-resolved on process restart.
- Cache lives on the adapter instance. The gateway's recent-senders map (`gateway._record_recent`)
  already stores `user_name`; ensure it's the resolved name.

---

## 3. Structured connector inbound messages (rich card)

Today a connector message reaches a session as a framed text blob. Make it structured so the GUI
renders a card (logo + channel/person names + hover ids + ts) while the model still sees framed text.

### 3.1 Data
- → `coworker/connectors/base.py` (or a new `messages.py`): `MessageSource` dataclass:
  ```
  connector: str         # platform id, e.g. "slack"
  kind: str              # "channel" | "dm"
  channel_id: str        # e.g. "C0BD7KZ1AH5"
  channel_name: str      # e.g. "#ocw-test"  (resolved; falls back to id)
  sender_id: str
  sender_name: str       # resolved; falls back to id
  ts: float              # epoch seconds
  text: str              # the RAW message (what the card shows)
  ```
- The persisted user message gains an optional sidecar. Decide the storage seam by reading
  `coworker/engine.py` + the conversation/session store:
  - The model-facing `content` stays the **framed** text (so replay keeps the agent's instructions:
    "💬 New message on {channel} from {sender}: {text}\n(You're subscribed… reply with send_message…)").
  - Attach `source` (the `MessageSource`) to that message record for **display only**. Persist it;
    **strip it before sending to the provider** (providers must not receive unknown keys). The
    cleanest approach: store transcript messages as the existing dicts plus a parallel
    `message_meta[index]` or an `ocw_source` key that the provider serializer drops. Pick whichever
    matches the current message model with least friction; document the choice in the PR.

### 3.2 Plumbing
- → `coworker/server/manager.py`:
  - `deliver_to_session(session_id, message, *, source: MessageSource | None = None)`. When `source`
    is set, the engine records the user message with the framed text **and** the source sidecar.
  - `_dispatch_inbound`: build `MessageSource` (resolved names from the event) and pass it through for
    both the channel-subscription path and the DM path.
- → `coworker/engine.py`: `run(...)` / the user-message append path accepts the optional source and
  stores it on the recorded user message; `TURN_START` event `data` includes `source` (so the live
  render shows the card immediately — see §1 event bus already broadcasts turn_start).
- → `coworker/server/app.py`:
  - `GET /v1/sessions/{id}/messages`: each message includes `source` when present.
  - WS `turn_start`: include `source` in the event `data` (already forwards `input`).

### 3.3 Frontend
- → `surfaces/gui/src/components/ConnectorMessageCard.tsx` (new): renders header (ConnectorBadge +
  `channel_name` + sender_name + relative time + "via {label}") with an **id-on-hover** swap
  (`channel_id · sender_id`), body = `source.text`. Brand-tinted header + left edge (mock parity).
- → `surfaces/gui/src/App.tsx`: the conversation item model gains an optional `source`. In
  `itemsFromMessages` and the `turn_start` handler, when a user message has `source.connector`, push a
  `{ kind: "connector", source }` item rendered by `ConnectorMessageCard` instead of the plain user
  bubble. (Generalizes to Salesforce/HubSpot via the registry.)

---

## 4. Connection hierarchy (data model)  — UX §4

Three layers. **No backward compat:** replace the current global per-connector `enabled` semantics.

### 4.1 Layers
1. **account-connected** — a connector profile with valid creds exists (`secrets[<name>:default]`).
   Unchanged (`connector_list[].connected`).
2. **persona-default-enabled** — per persona, which connected connectors are on by default for its
   sessions. **User-editable** (persona detail page), seeded from the manifest.
3. **session-override** — per session, explicit on/off overriding the persona default.

**Effective(session, connector)** = `connected` AND `session_override[c]` if present
**else** `persona_default[persona, c]`.

### 4.2 Stores (JSON, mirror `SubscriptionStore`/`UnroutedStore`)
- → `coworker/connections.py` (new):
  - `PersonaConnectionStore(path)` — `{persona_id: {connector: bool}}`. `defaults_for(persona_id,
    manifest)`: if no stored row, seed from the manifest — a `recommends` item with `tier: core` whose
    connector is **connected** → default **on**; everything else off. `set(persona_id, connector,
    bool)`, `get(persona_id)`.
  - `SessionConnectionStore(path)` — `{session_id: {connector: bool}}` (overrides only; absence =
    inherit). `set/clear(session_id, connector, bool|None)`, `get(session_id)`. `remove_session(id)`
    on delete (like subscriptions).
  - `effective(session_id, persona_id, *, connected: set[str]) -> dict[str, bool]` — the resolver.
- Manager owns both: `self.persona_connections`, `self.session_connections`.

### 4.3 Runtime gating
The effective set must actually change behavior:
- **Inbound** (`_dispatch_inbound`): a channel subscription belongs to a session; deliver only if the
  source connector is effective-enabled for that session. A muted connector → skip delivery (still
  buffer for catch-up).
- **Outbound/tools**: when building a session's engine, expose a connector's tools only if
  effective-enabled for that session (extend the existing connector-tool gating that already checks
  global enabled/experimental — read `agent.py` / `catalog` / `_enabled_connector_tools`).
- **DM routing**: a DM to the designated session is delivered only if that session has the connector
  effective-enabled.

---

## 5. Persona surface (recommends + detail page)

Manifest `recommends` already exists (`coworker/personas/manifest.py`, `Recommendation`). Surface it
+ the persona-default connections + identity to the GUI.

**Backend** → `coworker/server/app.py` + `coworker/personas/registry.py` + manager:
- `GET /v1/personas/{id}` → identity (id, name, icon, tagline, description), `tools`,
  `recommended_models`, `default_permission_mode`, `workspace`, `enabled` (persona on/off),
  `recommends: [{kind, ref, reason, tier, connected: bool}]` (annotate `connected` from
  `connector_list`), and `default_connections: [{connector, enabled, connected}]` (from
  `PersonaConnectionStore.defaults_for`).
- `POST /v1/personas/{id}/connections` `{connector, enabled}` → set persona default.
- `POST /v1/personas/{id}/enable` `{enabled}` → enable/disable the persona (persona registry already
  tracks enabled/surfaced; reuse it).
- `GET /v1/personas` (list) already exists for the picker — ensure it returns enabled personas with
  icon + name + tagline.

**Frontend** → `surfaces/gui/src/components/PersonaView.tsx` (new): the detail page (mock parity) —
identity + Enable toggle, About, capabilities (tools), "Connections for full benefit" (recommends,
core/optional + reason + connect state), "New sessions get by default" (default_connections toggles),
defaults. Opened from the grouped-nav gear and the New-session menu's "Manage personas…" (UX §9).

---

## 6. Per-session connections (Sources bar + drawer)

**Backend** → `coworker/server/app.py` + manager:
- `GET /v1/sessions/{id}/connections` →
  - `connected: [{connector, enabled, detail}]` — effective-enabled connectors for the session, with a
    short detail (e.g. Slack: "#ocw-test · DMs"). `enabled` reflects the session override/persona
    default so the drawer toggle shows correct state.
  - `recommended: [{connector, reason, tier, connected}]` — the persona's `recommends` **not yet
    connected** (drives the `⚠ N` count = `len(recommended where not connected)`).
  - `attention: int` — the `⚠ N`.
- `POST /v1/sessions/{id}/connections` `{connector, enabled}` → set the session override
  (mute/unmute). Clearing back to inherit = send `enabled` matching the persona default, or add a
  `clear` flag.

**Frontend** → `surfaces/gui/src/components/SourcesBar.tsx` + `SourcesDrawer.tsx` (new):
- Sources bar: icons-only avatar stack + `⚠ N` badge (UX §3); click opens the drawer.
- Drawer: persona why-connect blurb (from `GET /v1/personas/{id}`), Connected (with enable toggles →
  `POST …/connections`), Recommended connectors + MCP (Connect/Add), link to global Integrations.

---

## 7. Frontend — views, components, state

- **Session view**: add the Sources bar (§6) under the title; render `ConnectorMessageCard` (§3.3);
  wrap tool/approval items in a collapsed `<details>`-style **StepGroup** ("N actions · M approvals")
  — pure frontend grouping of existing `tool_*`/`permission_*`/approval items (UX §2b).
- **Per-session drawer** (§6) + **Persona detail page** (§5) as new routed surfaces; generalize the
  view switch (mock uses session/integrations/persona).
- **Integrations** → `surfaces/gui/src/components/IntegrationsView.tsx`: restructure into a left
  sub-nav: **Connectors · Messaging routing · Activity · MCP** (UX §6). Move the existing
  DM-route/subscriptions/approvals-routing controls under "Messaging routing"; Unrouted under
  "Activity"; allow-list stays inline on the Slack connector card.
- **Sidebar** → `Sidebar.tsx`: dual layout (flat ↔ grouped-by-persona) toggled by an icon by the
  wordmark; grouped = bounded per-persona cards with a gear → PersonaView (UX §7). Persist the choice
  in prefs (`GET/POST` the existing settings/prefs; add `nav_layout`).
- **New session**: split button — primary = last/default persona; ▾ = enabled personas (from
  `/v1/personas`) + "Manage personas…" (UX §8).
- **api.ts**: add typed clients + interfaces for every endpoint in §§3,5,6 and the connector
  `brand_color`/`logo` fields; add `MessageSource`, `PersonaDetail`, `SessionConnections`.

---

## 8. REST/WS contract (net-new / changed)

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/connectors` | + `brand_color`, `logo` per connector (§1) |
| GET | `/v1/sessions/{id}/messages` | + `source` on connector messages (§3) |
| WS  | `turn_start` | + `source` in event data (§3) |
| GET | `/v1/personas/{id}` | identity + recommends(+connected) + default_connections (§5) |
| POST | `/v1/personas/{id}/connections` | set persona-default connection (§5) |
| POST | `/v1/personas/{id}/enable` | enable/disable persona (§5) |
| GET | `/v1/sessions/{id}/connections` | connected + recommended + ⚠ count (§6) |
| POST | `/v1/sessions/{id}/connections` | set session override (§6) |
| GET/POST | settings `nav_layout` | persist flat/grouped nav (§7) |

---

## 9. Build phases (suggested)

1. **Foundation**: connector registry metadata (§1) + connector contract tidy-up (§2, incl.
   `resolve_channel_name`). Land FakeSlack (separate deliverable) so later phases test deterministically.
2. **Structured messages** (§3) — backend + `ConnectorMessageCard`. High user-visible value.
3. **Connection hierarchy** (§4) — stores + resolver + runtime gating. The core data-model change.
4. **Persona + session surfaces** (§5, §6) — APIs + PersonaView + SourcesBar/Drawer.
5. **Frontend polish** (§7) — Integrations sub-nav, dual nav, split button, StepGroup.

Each phase: code + tests (see VERIFICATION) green before the next. Commit per phase.

---

### Openworker Archive Docs UI REFRESH VERIFICATION (`openworker-archive/docs/UI-REFRESH-VERIFICATION.md`)

# UI Refresh — Verification & Test Plan

Companion to [`UI-REFRESH-SPEC.md`](UI-REFRESH-SPEC.md). Defines the test cases, the manual checks,
and the acceptance bar for each phase. The implementing agent must land these (green) alongside the
code. Several integration tests depend on the **FakeSlack** harness ([`FAKE-SLACK-SPEC.md`](FAKE-SLACK-SPEC.md)) —
build that first.

## How to run
- Python: `./.runtests.sh platform/tests/<file> -q` (uses the agent-platform venv). Whole suite:
  `./.runtests.sh platform/tests/ -q`. Baseline is green **except 3 pre-existing SDK-import failures**
  (`test_anthropic_provider`, `test_gemini_provider`, `test_provider_router`) — unrelated; don't
  "fix" by mocking the SDK.
- Frontend: `cd platform/surfaces/gui && npx tsc --noEmit` (must be clean) and `npm run build`.
- Server under the aisuite venv: `PYTHONPATH=platform /Users/rohit/fleet/ro4d/aisuite/platform/.venv/bin/python -m coworker.server.run --port 8765`.
  Dev GUI on :1420 defaults to backend 8765.

## Conventions
- Backend unit tests use `ScriptedProvider` + `SessionManager(workspace=tmp_path, provider=…)` and
  `TestClient(create_app(mgr))` (see `test_session_events.py`, `test_subscriptions.py`).
- Messaging integration tests use **FakeSlack** instead of the network: point the Slack adapter at the
  fake, drive inbound/button events through the fake's control API, assert on outbound calls it
  recorded. No `COWORKER_DEBUG_INJECT`, no real tokens.

---

## Phase 1 — connector registry + contract

**Unit**
- `test_descriptor_brand_logo`: every descriptor exposes `brand_color` (hex) + `logo`; `/v1/connectors`
  surfaces them; an unknown/placeholder connector still returns a color (fallback gray) + `logo`.
- `test_placeholder_connectors_listed`: github/datadog/salesforce/hubspot/pagerduty appear as
  `available:false` so personas can recommend them; they have no `connect`.
- `test_slack_resolve_channel_name`: `_channel_name(id)` returns the resolved name, **caches** (one
  `conversations.info` call for repeats), returns `None` on failure. (Pattern mirrors the existing
  `test_slack_resolves_and_caches_display_name`.)

**Frontend**
- `ConnectorIcon`/`ConnectorBadge` renders the registry SVG for a known id and the fallback for an
  unknown id; brand color applied from prop. (Component test or a Storybook-style render assertion.)

**Manual:** Integrations → Connectors shows correct logos/colors incl. a placeholder (e.g. Datadog)
with the fallback-or-brand badge.

---

## Phase 2 — structured connector messages

**Unit**
- `test_inbound_builds_message_source`: `_dispatch_inbound` of a channel `MessageEvent` (with
  `user_name`, `chat_name` set) delivers to the subscribed session with a `MessageSource`
  (`connector/kind/channel_id/channel_name/sender_id/sender_name/ts/text`).
- `test_message_source_persisted_and_stripped`: after the turn, `GET /v1/sessions/{id}/messages`
  returns the user message **with** `source`; the provider received the **framed text** and **no**
  `source`/unknown keys (assert against the `ScriptedProvider`'s captured `messages`).
- `test_turn_start_carries_source`: the WS `turn_start` event for a delivered connector message
  includes `source` (register a fake session client, like `test_session_events.py`).
- `test_dm_message_source_kind_dm`: a DM yields `kind="dm"`.

**Frontend**
- `itemsFromMessages` maps a message with `source.connector` to a `connector` item; `turn_start` with
  `source` renders `ConnectorMessageCard` (not a plain bubble). Hover swaps names→ids.

**Manual (FakeSlack):** post a channel message via FakeSlack → the session's open view shows the
connector card live (names, hover→ids, ts); reselect the session → the card persists (re-rendered
from `/messages`).

---

## Phase 3 — connection hierarchy

**Unit** (`test_connections.py`, new)
- `test_persona_defaults_seeded_from_manifest`: `PersonaConnectionStore.defaults_for(ops, manifest)`
  → core+connected recommends on, others off; persisted after first read.
- `test_effective_resolution`: with `connected={slack,github}`, persona-default `{slack:on,
  github:on, datadog:off}`, session override `{slack:off}` → effective `{github:on}` (slack muted,
  datadog not connected).
- `test_session_override_clear_inherits`: clearing a session override returns to persona default.
- `test_remove_session_clears_overrides`: deleting a session drops its overrides.

**Runtime gating**
- `test_muted_connector_not_delivered`: a session subscribed to a Slack channel but with Slack muted
  (session override off) does **not** receive `_dispatch_inbound` for that channel (still buffered).
- `test_muted_connector_tools_absent`: building that session's engine omits the connector's tools.
- `test_dm_muted_session_not_delivered`: DM routed to a session with the connector muted is parked/
  skipped, not delivered.

**Manual:** in a session's Sources drawer, toggle Slack **off** → posting in the subscribed channel
no longer wakes the session; toggle on → it resumes. Confirm the persona-level default is unchanged.

---

## Phase 4 — persona + session connection surfaces

**Unit (TestClient)**
- `test_persona_detail_endpoint`: `GET /v1/personas/ops` returns identity, tools, `recommends` with
  `connected` annotated, and `default_connections`.
- `test_persona_set_default_connection`: `POST /v1/personas/ops/connections {github,false}` flips the
  persona default; reflected in the next GET and in new sessions' effective set.
- `test_persona_enable_toggle`: `POST /v1/personas/ops/enable {false}` removes it from the
  `/v1/personas` picker list.
- `test_session_connections_endpoint`: `GET /v1/sessions/{id}/connections` returns connected +
  recommended + `attention` = count of not-yet-connected recommends.
- `test_session_set_override`: `POST /v1/sessions/{id}/connections {slack,false}` sets the override;
  effective resolution + `GET` reflect it.

**Frontend**
- PersonaView renders detail from the endpoint; toggling a default connection POSTs and re-reads.
- SourcesBar shows `⚠ N` = attention; opening the drawer lists connected (with working toggles) +
  recommended (Connect/Add).

**Manual:** open a persona's gear → detail page shows recommends with reasons + connect state + the
"new sessions get by default" toggles; flip one and start a new session of that persona → the default
is reflected in its Sources drawer.

---

## Phase 5 — frontend polish

**Frontend / manual**
- Integrations sub-nav (Connectors/Messaging/Activity/MCP); the three messaging controls live under
  "Messaging routing"; Unrouted under "Activity".
- Sidebar layout toggle persists across reload (prefs `nav_layout`); grouped view shows bounded
  per-persona cards with a working gear.
- New-session split button: primary starts last/default persona; ▾ lists enabled personas + opens
  PersonaView via "Manage personas…".
- StepGroup: tool/approval items render collapsed ("N actions · M approvals ✓"), expandable.
- `npx tsc --noEmit` clean; `npm run build` succeeds.

---

## Cross-cutting acceptance (end-to-end, via FakeSlack)

A single scripted scenario the FakeSlack harness can drive, asserting the whole refresh:
1. Connect Slack (against FakeSlack); allow a user; subscribe the Ops "incident" session to a channel.
2. Post a channel message → session shows the **connector card** live (resolved names).
3. Agent proposes a tool needing approval; session is Unattended with approvals routed to a channel →
   FakeSlack receives a **Block Kit** card; inject the **Approve** click → turn resumes, reply posts
   back to the origin channel (FakeSlack records the outbound).
4. Mute Slack for the session in the drawer → a further channel post does **not** wake it.
5. `GET /v1/sessions/{id}/connections` `attention` matches the persona's unconnected recommends.

This scenario lives as `test_ui_refresh_e2e.py` against FakeSlack and is the merge gate.

## Regression bar
- Whole suite green (minus the 3 known SDK-import failures).
- No real-network calls in tests (grep the diff for `slack.com`, real tokens — there should be none
  outside FakeSlack config).
- `tsc --noEmit` clean; `npm run build` passes.

---

### Openworker Archive Docs UX DECISIONS (`openworker-archive/docs/UX-DECISIONS.md`)

# UX Decisions — OpenCoworker GUI

**Owner:** Rohit. **Status of this doc:** living spec — the source of truth for *why the UX is the
way it is*. Every entry is a deliberate decision with a rationale.

> **For agents/contributors:** Do **not** restructure or "improve" any UX listed here without the
> owner's explicit sign-off. If a change seems warranted, propose it (add a `↪ Proposed` note under
> the relevant entry) and ask — don't silently override a decision. New UX requests get appended
> here with their rationale before/while they're built.

Mocks that realize these decisions live in `platform/ui-mocks/` (`redesign.html`). The mock is the
visual spec; this doc is the reasoning + data-model spec. **Implementation** is specced in
[`UI-REFRESH-SPEC.md`](UI-REFRESH-SPEC.md) (+ [`UI-REFRESH-VERIFICATION.md`](UI-REFRESH-VERIFICATION.md),
[`FAKE-SLACK-SPEC.md`](FAKE-SLACK-SPEC.md)).

Status legend: **Decided** (settled), **Proposed** (endorsed, not finalized), **Mocked** (shown in
the HTML mock), **Built** (in the real React/Python app).

---

## 1. Connector-agnostic source model
- **Decision (Decided / Mocked):** Every external source renders through one connector registry:
  `{ id → label, brand color, logo }`, with a **default fallback** (neutral plug glyph + gray) so an
  unknown/custom/not-yet-shipped connector still renders cleanly.
- **Rationale:** We're going multi-connector (Slack now; Salesforce, HubSpot, GitHub, Datadog,
  PagerDuty, Telegram, custom/MCP next). UI must never hardcode "Slack"; adding a connector should be
  a registry entry, not new components.
- **Data model:** the registry lives on the connector descriptor (brand color + logo id). Recommended
  connectors that we don't ship yet still get a label + fallback badge.

## 2. Session conversation
### 2a. Connector inbound-message card
- **Decision (Decided / Mocked):** Inbound messages from a connector get a dedicated card (not the raw
  `💬 New message on slack:C…` bubble): a subtle brand-tinted header with **connector logo +
  channel/entity name + person name + time**, body below, brand-tinted left edge.
- **IDs on hover:** the header shows friendly names by default; hovering reveals the raw IDs
  (`C0BD7KZ1AH5 · U07JK68S4BH`). Names come from connector resolution (e.g. Slack `users.info`).
- **Rationale:** IDs are noise in the common case but essential when debugging routing; hover keeps
  both without clutter. The card generalizes to Salesforce "Case #123", etc.

### 2b. Collapsible tool/approval steps
- **Decision (Decided / Mocked):** Tool calls + approvals (e.g. `send_message`, `write_file` approval)
  collapse into a single `<details>` disclosure (`"2 actions · 1 approval ✓"`), **collapsed by
  default**. Expand for the per-step detail (args, who approved, where).
- **Rationale:** The thread should read message → reasoning → reply. Mechanics are available on demand,
  not in your face. (Re-introduces the earlier collapsed-steps pattern.)

## 3. "Sources" bar + per-session connections drawer
- **Decision (Decided / Mocked):** Below the session title sits a **Sources** bar showing **connector
  icons only** (overlapping avatars) + a short "needs attention" badge. Clicking it opens a
  **per-session connections drawer** (right slide-over).
- **Attention badge:** use a compact **⚠ N** (yellow exclamation + count) to denote *N connections
  recommended but not yet connected* — short, not the verbose "N recommended". *(Req 2026-06-29)*
- **Drawer content (persona-aware):**
  1. A **why-connect blurb** for the session's persona ("Ops works best wired into GitHub, Slack, a
     monitoring dashboard…") + a small progress indicator (`2 of 4 connected`).
  2. **Connected** sources for this session (with live status **and an enable/disable toggle** — see §4).
  3. **Recommended connectors** and **recommended MCP servers**, each with **the value it unlocks**
     ("so I can pull the firing alerts"), tiered **core vs optional**, with Connect/Add.
  4. A link out to **global Integrations**.
- **Rationale:** Turns an empty integration state into a capability story; progressive setup the user
  can finish later (explicitly *not* gating onboarding). Per-session scope keeps it contextual; the
  drawer reuses the global Integrations visual language so it's one mental model.
- ↪ **Superseded (2026-07-11):** the always-visible bar + `⚠ N` badge is replaced by the
  session-settings row (rest = one icon; the nudge moves into the drawer) — see §23. The drawer
  itself lives on, renamed "Session settings" and extended with working directories.

## 4. Connection hierarchy: persona-level vs session-level enablement  *(Decided — data-model change)*
- **Decision (Decided 2026-06-29 by owner):** Today connectors are enabled **per persona**. We're
  introducing **per-session enable/disable**: a connection can be enabled for the persona but toggled
  **off for a specific session** (Connect *and* an Enable toggle in the drawer).
- **Hierarchy (confirmed):** `account-connected connector` → `persona enables it` → `session may
  disable it`. Effective state for a session = connected AND persona-enabled AND not session-disabled.
- **Rationale:** A session may want a narrower surface than its persona's default (e.g. mute Slack for
  one focused session) without disconnecting the account or editing the persona.
- **Open:** storage shape (a per-session override set, mirroring how Unattended/subscriptions are
  stored). To design before building.

## 5. Richer persona manifests — `recommends`  *(Decided → Built: data model)*
- **Decision:** Persona manifests gain a `recommends` list: each item references a `connector:` or
  `mcp:`, with a `reason` (the value it unlocks) and a `tier` (`core` | `optional`). This drives the
  drawer's persona-aware recommendations (§3) — data, not hardcoded UI.
- **Rationale:** "Good evolution before we go live." Recommendations must travel with the persona
  (incl. third-party personas), so they belong in the manifest, not the frontend.
- **Validation:** `recommends` is **not** strictly validated against shipped connector descriptors (a
  persona may legitimately recommend a connector we don't ship yet, or an external one). Only the
  structure (kind/ref/tier) is validated. *(Contrast: `tools` ARE validated against the catalog.)*

## 6. Global Integrations page
- **Decision (Decided / Mocked):** Replace the single long scroll with a **left sub-nav**:
  **Connectors · Messaging routing · Activity · MCP servers**.
  - *Connectors*: card grid; a connected two-way connector expands to its allow-list + recent senders.
  - *Messaging routing*: groups the three previously-scattered controls — channel subscriptions, DM
    routing, Unattended-approvals routing — into one panel.
  - *Activity*: the Unrouted/failed dead-letter table (with a count badge in the nav).
- **Rationale:** Things were "dumped one after another," hard to find. Grouping by intent fixes
  discoverability. (Global = account-wide; the per-session drawer in §3 is the scoped counterpart.)

## 7. Left navigation — dual layout  *(Decided / Mocked)*
- **Decision:** Support **two session layouts**, user-toggleable via a small icon next to the
  "OpenCoworker" wordmark:
  1. **Flat** — Pinned + Recent (current).
  2. **Grouped by persona** — sessions clustered under their persona.
- **Boundaries (Decided 2026-06-29):** the grouped layout's per-persona sections must have **clear
  visual boundaries** — render each persona group as a **bounded card** (faint surface + border +
  header), not just a text header (the first cut had indistinct boundaries).
- **Per-group gear → Persona page (§9):** each persona group header carries a **settings gear** that
  opens the persona's detail page.
- **Rationale:** Different mental models — time/recency vs. persona/role. Don't force one. Pins remain
  pure accessibility in both.
- **Open:** persist the choice (prefs); default = Flat.

## 8. "New session" — persona picker  *(Decided / Mocked)*
- **Decision (Decided 2026-06-29):** "New session" is a **split button** — the main action starts a
  session with the **last-used / default persona**; the **▾** opens a dropdown to pick from the
  **enabled personas** (icon + name + tagline) + "Manage personas…" (→ §9). Not a modal, not an
  always-dropdown.
- **Rationale:** Fast path for the common case + discoverable choice without a heavyweight picker.
  Personas are the product's organizing concept, so surfacing them at session creation is right.

## 9. Persona detail page  *(Decided / Mocked)*
- **Decision:** A per-persona detail page (opened from the grouped-nav gear, or "Manage personas…")
  shows: **identity** (icon, name, tagline) + an **Enable** toggle; **About**; **built-in
  capabilities** (tools); **Connections for full benefit** (the manifest `recommends`, §5 — core/
  optional + the value each unlocks + connect state); **"New sessions get by default"** (the
  persona→session default connections, each toggleable — the middle layer of the §4 hierarchy); and
  **defaults** (models, mode, workspace).
- **Rationale:** The owner needs one place to answer "what *is* this persona, what does it need
  connected, and what does a session inherit by default?" It's also where the persona-level layer of
  the §4 hierarchy is configured.

## 10. Persona enablement / onboarding  *(Proposed — tabled)*
- **Question (owner):** Enabling a persona is just a toggle today, but it implies setup (connecting
  the recommended sources). Should that onboarding happen **inside the first session**, or be a more
  **explicit upfront** step? Onboarding can be heavy if it requires connecting several sources.
- **Recommendation (mine):** Keep it **lightweight and progressive, never gating**. Enabling a
  persona flips it on and surfaces its recommended connections (reuse the §9 page / a slim "set up
  Ops" panel) where the user connects what they want **now** and the rest stay as `⚠ N` nudges in the
  session's Sources bar (§3) — completable later. The first session works immediately with whatever's
  connected; it just shows the nudge. Avoid a blocking multi-step wizard.
- **Status:** **tabled** for later by owner; design intent recorded so we don't accidentally build a
  heavy gated wizard.

## 11. Session top bar — no model / mode chips  *(Decided 2026-06-29 by owner)*
- **Decision:** The session top bar shows **only** the title · persona · ⋯ menu (left) and the
  persona/panel icons (right). The read-only **model** chip (`anthropic:claude-opus-4-8`) and
  **permission-mode** chip (`Interactive`) are **removed**.
- **Rationale:** Both were non-interactive `<span>`s that *duplicated* controls already in the
  composer — the model dropdown and the **"Ask for approval"** permission-mode dropdown — where the
  user actually changes them. Worse, the mode chip showed the raw enum **"Interactive"** while the
  composer labels the identical setting **"Ask for approval"**, so the two names read as two different
  things. The composer is the single source of truth; the chips were clutter. (The persona detail
  page §9 still documents the persona's *default* model + mode — that's a different surface.)
- **Note:** The three permission modes remain unchanged — **Plan** (read-only, propose first),
  **Interactive** = "Ask for approval" (asks before edits/commands), **Full access** (runs without
  asking). This is a separate axis from the composer's **Unattended** toggle (routes approvals to the
  Inbox for hands-off runs). Mock (`redesign.html`) updated to match.

## 12. Sidebar bottom — Inbox + a single ⚙ menu  *(Decided 2026-06-30 by owner)*
- **Decision:** The sidebar bottom was a stack of 5 rows (Integrations · Automations · Inbox ·
  Activity + a path/gear footer) — "very busy." Collapse it to **two rows**: **Inbox** stays visible
  (its attention badge must be glanceable), and **Settings · Integrations · Automations · Activity**
  move into one **⚙ "Settings & more"** click-to-open menu that opens upward (Codex/Claude-style). The
  current workspace path becomes the menu's header (was the standalone footer).
- **Rationale:** Inbox is the only high-frequency destination; the rest are occasional (Integrations
  is set-up-once, Activity/Settings are rare). Claude/Codex use a bottom menu for *account* items —
  we have no account (local, BYO-key), so ours holds *app* destinations instead. Net: 5 rows → 2,
  primary nav (New session, Search) stays clean at top.
- **Not chosen:** moving Inbox to the top cluster (kept it bottom-adjacent to its badge); keeping
  Integrations/Automations visible (folded them in for a cleaner bottom — promotable later if usage
  warrants).
- ↪ **Superseded (2026-07-11):** the no-account premise went stale when Phase 3 shipped cloud
  sign-in — the bottom is now a single account row with a state-driven inbox chip, and the ⚙
  menu became the account menu. See §26.

---

## 14. Per-session Slack channels — Sources drill-down  *(Decided 2026-07-01 by owner)*
- **Decision:** Managing which channels a session listens to belongs in the **Sources drawer**, not
  the composer's `+` menu. On a connected **two-way** messaging connector's row (Slack/Telegram), a
  **"Channels · N ›"** control opens a **child panel with a ‹ back button** — subscribed channels
  (× to stop listening) + an add picker (recent-channels datalist). The `+` menu stays about
  attachments only.
- **Rationale:** Sources is *the* per-session connection surface — it already owns "is Slack on for
  this session" (the mute toggle). Channels are the same category (per-session, connector-scoped
  standing config); the `+` menu is per-*message* attachment — a different mental model. Placing it
  on the Slack row makes it discoverable in context and gates itself (only shows when the connector
  is connected+enabled and two-way). The child panel gives the picker room and generalizes into a
  per-connector settings drill-down (allow-list, DM routing could live there later).
- **Reuse:** pure GUI — existing `subscribeChannel`/`unsubscribeChannel`/`getSubscriptions`/
  `getRecentChannels`; `two_way` read from the connector index the drawer already loads. No backend.
- **Not chosen:** the composer `+` menu (wrong mental model, needs conditional gating in the
  composer); inline row-accordion (cramped in the 420px drawer, clutters the Connected list).

---

## 13. Settings as a full page; Activity re-shelled  *(Decided 2026-07-01 by owner)*
- **Decision:** Retire the top-tab **ManageModal** and make **Settings** a full-page surface that
  reuses the Integrations shell (208px left sub-nav + centered panel + `PanelHead`). Split by scope
  (**Option 2**): Settings holds the *local/app* concerns — **Appearance · Files · Models · Personas**;
  anything *external* (Connectors · Messaging · MCP · Activity) stays under **Integrations**. The
  **Activity** page (old `AuditView`) moves onto the same page shell, dropping the legacy `page-view`
  layout and its duplicate header.
- **Rationale:** The modal was the last top-tab surface and the last `page-view` straggler, and it
  *duplicated* MCP/Connectors that already live under Integrations. One page idiom everywhere; no
  duplicated homes; more room than a modal. Models + Personas field bodies were re-skinned to the
  Tailwind card idiom to match Appearance/Files (left-aligned segmented control, carded config).
- **Wiring:** the ⚙ menu's *Settings*, the desktop tray's Settings event, the composer's "no model"
  chip, and the persona-card gear all route to `surface: "settings"` with an initial section. The
  shared tab bodies (`ModelsTab`, `ConnectorsTab`, `McpTab`) moved to `ManageTabs.tsx`; dead
  `SettingsTab`/`AuditTab`/`ManageModal`/`UnattendedToggle` removed.
- **Not chosen:** Option 1 (one unified Settings+Integrations hub) — cleaner long-term but a bigger
  rebuild and a very long single nav; the Settings/Integrations split reads more naturally.
- **Follow-up:** dead-CSS pass for now-unused modal/legacy classes (`manage-*`, `mtab`, `page-view`,
  `sa-view-*`, `persona-row`, `persona-install`, `consent-card`, `audit-*`); sync `redesign.html`.

---

## 15. Persona Gallery as a modal; delete; team-sharing-ready  *(Decided 2026-07-03 by owner)*
- **Decision:** The Gallery leaves the inline Settings ▸ Personas section and becomes a
  **screen-sized modal** opened from a "Browse the Persona Gallery" link on the Personas page
  (modal, not a route — installs finish back on Personas, disabled pending consent). Modal anatomy:
  header (title · search · close) + source chips (**All · From OpenCoworker · From your team**),
  a **featured carousel** (publisher-flagged `featured` cards, user-scrolled — never auto-rotating),
  and the catalog **list**; every card opens the in-modal **solo page** (hero + pitch + locally
  derived capabilities) — install only happens there. Personas page rows gain a **delete**
  affordance (non-builtin only, inline confirm, works signed out).
- **Not chosen:** loading cloud-served HTML in an iframe — it would reopen the
  "cloud describes capabilities" channel the solo-page design deliberately closed, need a spoofable
  postMessage install bridge, and break offline/theming/e2e. Rich showcase comes from publisher
  markdown + images rendered by our own components instead.
- **Visuals:** connector chips show hand-drawn SVG **brand marks** (`brandIcons.tsx`, neutral plug
  fallback); personas without publisher imagery get a deterministic **generated hero**
  (`PersonaHero.tsx`, hue from slug) so the carousel/solo pages never look empty.
- **Team-sharing-ready (design-only for now):** the "From your team" chip + empty-state teaser ship
  now; publish-to-tenant later reuses the gallery's existing `tenant_only` visibility. Team personas
  get **zero extra trust** — same validation, local capability derivation, disabled-until-approved.
- **Updates (design-only):** installed personas will record source (slug+version+hash); update
  re-runs consent **iff the capability surface changed** (never a silent permission expansion).

---

## 16. Workspace enum collapsed into family  *(Decided 2026-07-03 by owner)*
- **Decision:** The persona `workspace` enum (`git | project | deliverable | none`) is retired as a
  behavioral axis. `family` alone decides: **knowledge → transparent per-conversation scratch**
  (real folders added as session roots when needed; no folder gate, ever); **code → explicit
  directory picked by the user** (gate + project-grouped sidebar). Manifests may still carry the
  key (parsed + typo-checked for back-compat) but it's inert — the effective value derives from
  family. Built-in Ops moves to scratch + multi-root; Chat keeps `none` via its builder.
- **Rationale:** the only combo the enum enabled — knowledge+`project` (Ops) — predates multi-root
  knowledge sessions, which cover "work against a real folder" strictly better (progressive, no
  modal wall). The two-axis model split behavior across code paths (engine branched on family, the
  gate/sidebar on workspace) and produced the 2026-07-03 smoke-test contradictions: a gate demanding
  a folder while a scratch-backed chat ran behind it, and grouped-sidebar sessions with no home.
- **Future:** a `git: true` refinement of code-family may later force "start from a git repo", with
  clone-into-scratch as the safe execution mode. Personas may also *suggest* a root ("works best
  with a folder attached") — a banner, never a wall.

---

## 17. The model is fixed per session  *(Decided 2026-07-04 by owner)*
- **Decision:** a session's model is chosen up to the first turn, then **locked for the session's
  life**. The composer's model picker is interactive on a fresh session and becomes a read-only
  pill afterwards ("start a new session to switch"). Enforced **server-side** — the first
  user_message's `model` binds the engine; later message models and `set_model` are ignored —
  not just in the GUI, so API callers and socket races can't rebind a running conversation.
- **Mechanism:** every user_message carries the composer's visible model (the first one binds).
  This replaced a separate `set_model` handoff that could race the socket lifecycle (silent no-op
  before the socket exists; losable during the reconnect every new cowork session does to adopt
  its scratch dir; `ready` overwriting the visible selection) — the owner's repro was picking
  Opus and getting Kimi.
- **Rationale:** sessions are task-scoped; mixed-model transcripts invite provider-quirk breakage
  (tool-call replay, vision content), thrash prompt caches, and make behavior impossible to
  reason about. Mid-session switching was inherited chat-app convention, never a designed feature.
- **Future (owner, 2026-07-04, not built):** mid-session switching may return as a *designed*
  feature — a compatibility-matched switch, not a free dropdown: only models whose capabilities
  cover what the transcript already uses (tool-calling always; vision iff images are in history;
  reseller/vendor quirks vetted via the model matrix), with an explicit affordance + warning.
  Also covers the provider-died-mid-session case. Until then: start a new session.

---

## 18. Disable a persona = archive its conversations  *(Decided 2026-07-04 by owner)*
- **Decision:** disabling a persona **archives all of its real sessions** (unarchived,
  non-internal) in the same server-side action. Its sidebar section then disappears naturally —
  the grouped layout's never-orphan rule ("a persona with unarchived sessions always gets a
  section") stays untouched, because after the archive there is nothing left to orphan. No
  greyed-out sections, no time-based ("inactive for N hours") heuristics: every sidebar
  visibility change traces to an explicit user action.
- **Confirm:** unchecking *Enabled* on Settings ▸ Personas only **arms an inline confirm** when
  the persona has conversations — "Disabling archives its N conversations — they stay available
  under 'Show archived'" with Disable / Keep enabled (the same two-step idiom as row delete).
  With zero conversations the checkbox flips instantly; the confirm exists for the side effect,
  not for ceremony.
- **Re-enable never unarchives.** That would overwrite the user's archive state; history returns
  one click at a time via the Show-archived disclosure. The archive step lives in the manager
  (`set_persona_enabled`), so both persona routes — and any future client — share the semantic.

## 19. Unauthorized senders: park, don't drop; connector card is the config surface  *(Decided 2026-07-04 by owner)*
- **Problem:** first contact on a two-way connector took a double-send — the allow-list
  (correctly closed by default) silently dropped the first message; the sender only appeared
  under "Recent senders", and after being allowed had to message AGAIN.
- **Decision:** an allow-list drop **parks the message** instead of losing it. The connector's
  expanded card shows "Messages from senders you haven't allowed" with three resolutions:
  **Allow & deliver** (allow-list the sender AND re-inject the original message through the
  normal inbound path — buffer + subscriptions — no re-send), **Allow only** (future messages
  flow; this one is discarded), **Dismiss** (throw away, nothing else changes). Parked items
  are capped and persisted (`parked.json`).
- **The expanded connector card is the one-stop config surface** for a two-way connector:
  tools, allow-list + recent senders, parked messages, and "Sessions listening" — the
  per-connector cut of the global Channel-subscriptions table (which stays under
  Integrations ▸ Messaging routing; the owner looked for it on the connector and couldn't
  find it there).
- **Tokens hot-reload (no restarts):** a platform socket authenticates at connect time, so new
  creds require reopening that socket — and nothing else. Connect/disconnect of a messaging
  connector now refreshes the gateway listeners in-process; the sidecar never restarts. (Found
  when pasted Slack tokens "did nothing": the listener only started in the app lifespan.)
- ↪ **Partially superseded (2026-07-08):** the parked/allow-list semantics stand, but the config
  surface moves from the expanded card to the connector's **detail subpage** — see §21.

## 20. Collapsible left nav + RECENT header group/filter  *(Decided 2026-07-05 by owner)*
- **Decision:** The left nav gains three refinements (extends §7):
  1. **Collapse (⌘B) with hover-peek.** The nav can collapse so the content reclaims the width
     (grid → single column; the sidebar is taken out of flow). Collapsed, hovering the left edge
     (`.nav-hover-zone`) *peeks* it back as a floating overlay (shadow, over content, auto-hides on
     leave); ⌘B, the brand pin button, or the floating reveal button (`.nav-reveal-btn`, cleared
     past the traffic lights on desktop) dock it. Collapse is persisted per-device (localStorage).
  2. **RECENT header owns grouping + filtering.** The old brand-bar layout toggle is gone; the
     brand bar now holds only the wordmark + the collapse/pin control. A **RECENT** section header
     (like PINNED) carries a sliders control that opens one popover with **Group by** (Persona =
     the accordion ↔ Chronological = the flat list) and **Filter by coworker** (persona checkboxes;
     none checked = all shown). The popover stays open so you can group AND filter in one visit.
  3. **Artifact preview auto-collapses the nav.** Opening a full artifact preview (PDF/webpage/
     sheet) collapses the nav for max width and restores it on close — unless you manually toggled
     the nav meanwhile (the manual action takes control; the auto-collapse never overwrites the
     saved pref).
- **Inspiration (not copied):** Claude/Codex collapsible sidebars + their group/filter menus.
- **Tests:** `e2e/nav-collapse.spec.ts` (collapse/dock, ⌘B, popover grouping); `Sidebar.test.tsx`
  updated to the new group/filter control.

## 21. Connectors redesign: connected-first list, detail subpages, add-modal, privacy filters  *(Decided 2026-07-08 by owner; mock: `ui-mocks/connectors-redesign.html`)*

Driven by managed-Slack going multi-workspace (M3.5): the connector card had outgrown itself, and
the interim "Slack workspaces" Integrations tab was the wrong shape. One pattern for ALL connectors:

- **Navigation:** the Integrations sub-nav stays fixed (Connectors · Messaging routing · Activity ·
  MCP servers) — **no per-connector nav items**. Each connected connector opens a **detail subpage**
  under Connectors (breadcrumb `‹ Connectors`). Slack manages workspaces there; Gmail accounts;
  HubSpot portals; Teams orgs. Supersedes both the expanded-card config surface (§19) and the
  short-lived "Slack workspaces" tab.
- **Connectors list:** **Connected first**, in its own section — single-column rows with brand
  badge, one-line status, a **health chip** (● Live / ⚠ Reauthorize / ● Ready) and a chevron; the
  row itself navigates. "Available" below (row list + Connect pills), long tail behind "show all".
  Cloud sign-in shrinks to a slim strip. Problems must surface **in the list**, not after a click.
- **Visual grammar:** macOS System-Settings style — grouped inset lists on gray paper, hairline
  separators, 44px rows, pill buttons, sentence-case group headers, minimal copy (footnotes, not
  paragraphs; IDs on hover). Owner: "I really like Apple for their aesthetics"; v1 was too verbose.
- **One entry point to add a connection:** the header button (`＋ Add workspace` / `＋ Add account`)
  opens a **modal** with a One click | Manual pill switcher, each pane carrying its instructions
  (Slack: relay vs Socket-Mode tokens; HubSpot: OAuth vs private-app token). **Modal only when a
  connector has ≥2 connect modes** — single-mode connectors (Gmail, Teams) launch directly. The
  bottom-of-page "Add a …" sections are gone (duplicate CTA).
- **Multi-account everywhere:** Slack workspaces, Gmail accounts, HubSpot portals, Teams orgs —
  same skeleton: one group per account with per-account auth state and Disconnect. A **Default**
  badge answers "which account do tools use?"; sessions can override in Sources; **every approval
  card names the account/portal it will act on** (a sandbox test can never quietly hit production).
  Profile keying generalizes the Slack pattern: `gmail:account:<email>`, `hubspot:portal:<hub_id>`.
- **Tools are a collapsed disclosure on every detail page** ("Tools · 3 of 5 enabled") — the lever
  exists everywhere but stays quiet; write tools carry "asks first" tags; destructive tools (e.g.
  HubSpot delete) are **never offered**.
- **Connection health = three honest layers** (Slack page): desktop↔relay WS (Live/Reconnecting/
  Offline + last event), cloud sign-in (required for relay), per-workspace/account token health.
  We never claim "Slack↔cloud is down" — event silence is indistinguishable from a quiet workspace.
- **Privacy filters, enforced at the DESKTOP, silent to agents:** the invariant is **"the cloud
  knows routing; the desktop knows content and policy."** Gmail: "Never show agents" senders/
  domains/labels — filtered mail **does not exist** from the model's perspective (no tombstone:
  `<truncated>` markers leak sender/subject and invite the model to reason around policy); the
  user sees "N hidden by filters" out-of-band on the tool card (MessageSource-sidecar pattern) +
  audit entries. HubSpot: **hidden fields** (property denylist stripped before model context) +
  **read-only vs read & write chosen at consent time** (scope minimization is the real ACL) +
  "agents see what the connecting HubSpot user sees" (record ACLs belong server-side in HubSpot
  permission sets — client-side per-record filters are theater and are deliberately NOT built).
- **Teams (concept):** relay-only (bots need a public endpoint — no manual mode). Consent is
  self-serve: chats = user installs it; channels = any **team owner** (RSC, per-team consent);
  org-blocked = Teams' native "Request approval" asks IT once. No tenant-admin Graph permissions
  by design — same per-actor privacy posture as the Slack relay.

## 22. Session screen cleanup: contextual top-left cluster, facts subtitle, three-control composer  *(Decided 2026-07-11 by owner; mocks: `ocw-context/docs/ux-improvements/mocks/UX-002-session-screen.html`)*

Owner sketch (hand-drawn) → discussed and resolved in the UX ledger (ocw-context, UX-002). Both the
fresh-session and in-progress screens shed chrome:

- **Top-left cluster `[sidebar] [+ new session] [search]` renders only when the sidebar is
  collapsed** — the expanded sidebar already owns those actions; never duplicate them. Build note:
  the cluster sits inside §20's hover-peek zone — the peek must not fire while the cursor is on
  these icons (start the zone below the icon row or add a short delay; the pin/reveal co-location
  logic already handles this corner). Keyboard shortcuts stay global regardless of sidebar state.
- **Centered title with a facts subtitle** beneath: `(Coworker · Opus 4.8)`; code sessions include
  the workspace folder. The subtitle is the session's **fixed facts, not controls** — it replaces
  the locked-model pill (§17's lock expressed spatially) and the topbar "About this persona"
  sliders button (subtitle click → the coworker page). Topbar right: the **panel toggle only**
  (mirrored variant of the left nav's sidebar glyph, one glyph both states); the right rail
  absorbs **artifacts only**. *(Revised at visual review 2026-07-11: the topbar
  session-settings icon was dropped as redundant — §23's row owns the drawer.)* *(Revised
  again 2026-07-11: the ⋮ conversation menu is REMOVED — the nav row's hover cluster owns
  pin/rename/archive/delete, so the topbar menu was a strict subset; the title STAYS (with
  the sidebar collapsed it is the only session identifier, and the subtitle orphans without
  it). The topbar goes edgeless: no bottom border, paper-tinted glass — invisible at rest,
  frosts only when the transcript scrolls under it.)* *(Revised 2026-07-11, third pass —
  ledger UX-008, mock `UX-008-merged-topbar.html`: the §23 session-settings row DOCKS into
  the bar's left region — one bar, not two strips. With the nav expanded the settings icon
  is the first element after the panel edge; collapsed, it follows the [sidebar][+][search]
  cluster. The §23 contract is unchanged.)*
- **Composer = `[+ attach] [Mode ⌄] [send]`.** The **Mode menu** carries the five permission
  options (Discuss / Plan / Ask for approval / Full access / Custom) **plus the
  Unattended/send-approvals-to-Inbox toggle** at the bottom — "who approves, and when" is one
  mental model; the separate InboxControl leaves the row. *(Revised 2026-07-11, competitor
  composer comparison: the trigger is borderless and names the CHOSEN mode — "Ask for
  approval ⌄", not a generic bordered "Mode ⌄" pill.)*
- **The model picker appears only on a fresh session** (quiet chip on the composer's right);
  after the first turn the fact moves up to the subtitle. No interactive-then-disabled control.
- **Folder/roots control and branch chip leave the composer** → the session-settings drawer
  (§23). Folder access is standing session config, not per-message attachment — the same
  reasoning §14 used to keep channels out of the `+` menu. The `+` menu stays attachments-only.
- **Open at build time:** fresh-session greeting copy; whether suggestion chips survive.

## 23. Session settings row: hover to glance, click to manage  *(Decided 2026-07-11 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-003-session-row.html`)*

> **↪ RETIRED 2026-07-13 (§32, ledger UX-014):** the row, the glance, and the drawer merged into
> the right rail's **Access** section — the glance summary now lives permanently on that
> section's header, and the drawer's content edits inline in the rail. The trust semantics below
> (brand = live, gray = unavailable, nudge text never at rest) carry over in summary form.

Replaces §3's always-visible SourcesBar (ledger UX-003). One sub-header row above the conversation
whose contract is **rest = icon · hover/focus = glance · click = manage**:

> **↪ Geometry revised 2026-07-11 (ledger UX-008):** the row DOCKS into the topbar's left
> region — the standalone strip under the bar is gone (one 48px bar, ~36px returned to the
> conversation). Everything below — the rest/glance/click contract, gray-is-the-nudge, zero
> reflow, deep links — is unchanged; only where the row renders moved.

- **Rest:** a single quiet icon, constant row height. **No nudge text at rest, ever** — the
  "recommended source not connected" nudge lives only in the drawer.
- **Hover/focus** (~150–200ms reveal delay so mouse-crossing doesn't flicker; reveals on keyboard
  focus too): a glance strip — **connected source icons in brand color**, persona-**recommended
  but unavailable ones in grayscale** (the gray icon IS the nudge, wordless), a **folder count**
  ("2 folders"; code sessions show the folder *name* — the workspace is the session's identity),
  and a trailing **"Configure ›"**. Icons only, no labels or chips. **No reflow** — row height is
  identical resting and hovered.
- **Gray covers both** "not connected" and "connected but muted for this session" (§4 override):
  the strip answers "what can *this session* touch right now"; tooltips disambiguate. Only
  persona-recommended connectors ever appear gray — never the whole catalog.
- **Everything in the glance is a shortcut:** icons and the folder count click straight into the
  matching drawer section. Tooltips are load-bearing once labels are gone (per-icon name + state;
  folder paths on the count).
- **Click:** opens the drawer, renamed **"Session settings"** — sources (connect-in-context,
  channels child panel §14, mute toggles §4) plus a new **working directories** section (roots
  list, add/remove, branch). ⚠ recommendations render here.
- **Rejected:** the icon morphing to "Click to configure" on hover (self-narrating UI, layout
  shift, permanent noise — affordance comes from the glance's content being clickable); at-rest
  nudge text (owner call: drawer only); placing the icon in the topbar with an anchored popover
  (max-clean but spatially disconnected — in-place morph keeps discoverability; cf. the
  cloud-sign-in placement regression, 2026-07-09).

## 24. First-run onboarding: model → recipe → tips  *(Decided 2026-07-11 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-004-onboarding.html`; ledger UX-004)*

Replaces the settings-shaped first run. Three steps; **only step 1 gates** (§10's
progressive-never-gating rule):

- **1 — Connect a model.** Fields adapt per provider from the existing descriptors: key-only
  (Anthropic/OpenAI/Gemini, each with a create-a-key deep-link), endpoint-only (Ollama, prefilled
  — the free/local escape hatch), endpoint+key (Fireworks etc.). One **default-model dropdown**
  per provider (curated matrix, recommended pre-selected) — deliberately *not* an
  enable-checklist; curation stays in Settings ▸ Models. Inline Test/verify.
  *(Revised 2026-07-11 at owner's Mac-app walkthrough: headline = **"Welcome to
  OpenCoworker"** with "connect a model to get started" as the sub-line; native `<select>`s
  → the Settings-Models **SelectMenu** (sectioned Ready-to-use / Needs-setup, key-set dots);
  the **default-model dropdown is DROPPED** — the model is per-session (§17) and the old
  select never persisted anything — replaced by one pointer line to Settings ▸ Models; an
  optional endpoint (base_url with a default, on a keyed provider) collapses behind a
  **"Configure custom endpoint ›"** link (keyless providers keep it visible — the endpoint IS
  the connection); Test joins the action row: Skip setup … [Test] [Continue], status line
  fixed-height above it.)*
- **2 — Get your first automation running** (skippable). **Role tabs — Engineering · Sales ·
  Everyday** — each with a recipe one-liner, two connect rows, then the recipe card (source ·
  channel/time · cadence · consent). **One Cloud sign-in, lazily triggered by the first Connect**
  — never per-integration; tokens-stay-local copy on the pane. **Connections persist across
  tabs.** Channel fields carry the invite-@ocw hint. **Everyday** = morning brief (Calendar +
  Gmail) delivered in-app, Slack DM secondary. **Create automation enables only when the tab's
  connectors are connected.** The consent line — "post the digest to #X without asking each time;
  anything else still asks first" — is a **standing scoped approval: ledger UX-005, design
  pending — this section's BUILD IS BLOCKED on it.**
- **3 — You're set up.** Recap card of the created automation (absent if skipped) → Specialist
  coworkers tip (Show me → gallery) → one quiet session-control line, with **"Start working"
  opening the first session with the session-settings panel (§23) open** — teaching by landing,
  not telling.
- **Deferred (owner):** tab choice seeding which specialists the gallery features — revisit after
  the UI cleanup lands.

## 25. Standing scoped approvals for automations  *(Decided 2026-07-11 by owner; ledger UX-005 — unblocks §24's build)*

Recurring automations using gated tools (e.g. `send_message`) park an approval **every run** —
a weekly summary needing a weekly click isn't an automation. The fix is a remembered,
narrowly-scoped rule: *"this automation may call this tool against this exact target without
asking"* — **tool + target + owner (task id)**, none optional, no wildcards in v1. Rules live on
the `ScheduledTask` record (revocation on the task detail page; deleted with the task).

- **Minted on exactly two human-only surfaces — the model can never mint a rule:**
  1. **The creation consent card** (already exists — `create_scheduled_task` is approval-gated).
     Every automation surfaces its needs at creation: reads render as *disclosure* lines
     (read-only, no gating), writes are the *grants*, pre-set to allow. The agent path proposes
     the set via a new `permissions` field on the create-tool schema; the existing card renders
     it. **Rejected:** the agent writing `config.toml` — model-authored permission expansion in a
     global file, invisible in UI, outlives the automation.
  2. **"Allow every time"** on a recurring run's approval card — persists to the task record
     (unlike the session-scoped Always-allow). In-app only; not offered on Slack-mirrored
     buttons. The retrofit path.
- **Graceful degradation:** no grants → the automation still works; runs park approvals in the
  Inbox as today.
- **Invariants:** never offered for `risk=exec`/destructive tools (shell asks forever); additive
  on top of the run's permission mode — never a silent full-access upgrade; every auto-allowed
  call audits the rule; cards/audit name the account acted on (§21); persona install consent
  stays availability-only — installs never ship pre-approved writes.
- **Infra note:** `always_allowed_tools` + creation gating + run-time wiring already exist
  (`automation/models.py`, `manager.py`); the build is target-shaping those entries, the
  `permissions` create-field, the task-persistent Allow-every-time, and lifting `permissions.py`'s
  connector exclusion **for task-scoped, target-matched rules only**.

## 26. Sidebar bottom: one account row; Connectors renamed; Activity dedup  *(Decided 2026-07-11 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-006-sidebar-account.html`; ledger UX-006)*

Supersedes §12's bottom cluster. §12's rationale ("we have no account, so the bottom menu holds
app destinations") went stale when Phase 3 shipped cloud sign-in two days later; the slot becomes
the **account anchor** and the bottom is **exactly one row**:

- **Account row:** avatar/initials + first name (email lives in the menu header, never on the
  row) + a green dot when signed in to OpenCoworker Cloud; signed out = "Not signed in" and the
  menu leads with the sign-in CTA. No workspace-path header (the path lives in Settings ▸ Files).
  Telemetry toggle moves to Settings. "Settings & more", the Inbox row, and any Connectors row
  are retired.
- **State-driven inbox chip with a sticky unlock** (owner: many users never park an item or use
  Unattended — a permanent Inbox row is dead chrome for them). Absent until the first item ever
  parks or Unattended is first enabled, then permanent: quiet icon when empty, accent + count
  when pending — §12's glanceability requirement, paid only when there's something to glance.
  Auth-independent (Inbox is local). **Two click targets:** the chip → Inbox directly; anywhere
  else on the row → the menu.
- **Menu (fixed):** email header · **Inbox** (with count) · **Connectors** · Settings (⌘,) ·
  Automations · Activity · Sign out. Inbox + Connectors are always listed — the permanent
  discoverable path regardless of chip state. **"Integrations" is renamed "Connectors"**
  everywhere (the findability complaint was the "& more" label; users think "connect Slack";
  MCP servers sit fine under the Connectors roof). "Inbox" keeps its name — "Approvals" was
  considered and rejected: the queue also holds questions, plan reviews, and folder-grant
  requests.
- **Activity dedup:** two unrelated pages were both named "Activity". The Integrations
  dead-letter page dissolves into **Messaging routing** as an "Unrouted" section (badge kept);
  the one remaining Activity = the audit log, in the account menu.
- **Automations** stays in the menu for now (owner: deserves a visible row, deferred). The
  Connectors-page sign-in strip retires — the account row supersedes it; connect-modal inline
  sign-in panes stay (§24's lazy trigger). Designed-not-built: Messaging routing keeps
  shrinking (§19/§21 moved per-connector cuts onto detail pages; the residual global table may
  later dissolve entirely).

> **Revision 2026-07-12 (§28):** Messaging routing did dissolve — the whole page (mirror
> channel, DM route, channel subscriptions, Unrouted with its ⚠ badge) moved to
> **Inbox ▸ Configure**; the Connectors sub-nav is now just Connectors · MCP servers.

## 27. Start screen: template tasks carry their own setup  *(Decided 2026-07-11 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-007-start-tasks.html`; ledger UX-007)*

Extends §22's start-screen half and composes with §23. The fresh-Cowork empty state becomes
**exactly three concrete template tasks** — flat hairline rows (the de-boxed grammar), staggered
~300ms entrance — and nothing else between the greeting and the composer:

1. **Analyze the files in a directory** — action "Pick a folder →": shares a folder (inline
   add-folder form; straight to prefill when one is already shared), then prefills the composer.
2. **Create a report from my HubSpot leads** — gated on HubSpot.
3. **Automate a weekly GitHub progress report to Slack** — gated on GitHub + Slack; funnels
   into §24/§25's recipe + consent machinery.

- **No leading icon tiles** — the title is the row. Connector dots sit on the **sub-line**:
  brand color = connected and enabled for this session, grayscale = not (§23's vocabulary).
- **Sub-line copy is always the task's outcome** ("Sources, stages, and who needs follow-up"),
  never connection state — the dots and the trailing action carry that.
- **Row action contract:** sources ready → "Start →" revealed on hover, click prefills the
  composer with the template stem. Not ready → **"Configure ›" always visible** (for a gated
  row the setup action IS the row's meaning) and it opens the §23 Session settings drawer —
  the start screen adds **no second setup surface**.
- **"Set me up (optional)" is removed** — setup rides the task that needs it.
- Rejected on the way (competitor comparison): boxed category tiles expanding into template
  lists (reintroduces boxes + a navigation level); a specialist-coworker picker line on the
  composer edge and a tip/picker under the tasks (placement + "X is on this session" copy).
  The specialist entry point is **deliberately absent** — owner sketch to come.
- Same day: the ✳ greeting/boot/gate mark (read as a competitor's logo) → **✦** app-wide.

## 28. One page shell; Inbox absorbs Messaging routing  *(Decided 2026-07-12 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-009-inbox-merge.html`; ledger UX-009)*

Owner walkthrough: Automations, Activity, and Inbox each had their own indentation and head
style; and "Messaging routing" hid inbox-delivery config under Connectors while the mirror
channel was editable in TWO places (that page's card + Inbox's inline configurator).

- **One page shell for every top-level page** — the Connectors/Activity pattern: full-bleed
  `main`, centered ≤4xl column, `PanelHead` (18px title, 12.5px muted subtitle BELOW it),
  card-based content. Page-level actions ("+ New automation") right-align with the head.
  Automations drops its icon-in-title and the boxed ⓘ banner (now a one-line muted note);
  Inbox drops its title+subtitle-on-one-line head. No page invents its own indentation again.
- **Inbox = two page-level tabs**, underline style — one visual level above the filter chips:
  - **Pending** (default): approvals/questions exactly as before (kind chips, persona chips,
    resolve-releases-agent). Badge = pending count. The routing status is a read-only line
    ("Also delivered to #ops-alerts — replies there resolve items here. Configure ›") whose
    link switches tabs; the inline editor is deleted — the mirror setting has ONE editor now.
  - **Configure**: the former Messaging-routing page moved whole — Unattended-approvals
    mirror + Direct-messages route (two-card row), Channel subscriptions, Unrouted. The ⚠
    unrouted count rides this tab. Rationale: Unrouted is "messages that never reached you" —
    the user asking "why didn't I get pinged?" looks in Inbox, not Connectors.
  - Tab name: owner offered "Destination"/"Configure" over the drafted "Delivery" —
    **Configure** won (honest umbrella for mixed settings; "Destination" names only the
    mirror card). Mirror target and routing line show the channel **name** when the recent
    list knows it (§24 revision 9's names-over-ids rule).
- **Connectors sub-nav shrinks to Connectors · MCP servers** (amends §26, which had already
  predicted the dissolution).

## 29. Onboarding: model → your tools → go; the recipe becomes the Automations quickstart  *(Decided 2026-07-12 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-010-onboarding-quickstart.html`; ledger UX-010)*

Restructures §24's step 2–3. The recipe step was the heaviest screen in the app — six decisions
and two third-party OAuth flows at minute one; nearly every friend-install failure lived on it —
and it duplicated the Automations page's template system.

- **Onboarding = three fixed-height pages, one decision each:**
  1. **Model** — unchanged (§24 + its revisions).
  2. **Sign in for one-click connections** *(amended 2026-07-16, owner design — resolves the
     copy iteration that was pending here)* — the VALUE is the headline and there is ONE
     primary action: the footer is the flow's standard pair — quiet "Skip for now" left,
     primary **Sign in** right, which becomes **Continue** once signed in. (The old page was
     headlined "Connect your tools" but offered no connecting, and its mid-page sign-in
     button competed with a footer Continue that did something else.) The security story is
     one titled card — **"Secure by design"**: broker framing ("OpenCoworker Cloud brokers
     the OAuth handshake — your tokens never leave this Mac") with the manual-keys path as
     the card's divider-set footnote. Real connector logos kept (ConnectorIcon set, never
     letter stand-ins); sign-in stays genuinely optional (the lazy first-connect sign-in
     remains for skippers); signed-in replay shows the ✓ state.
  3. **You're set up** — two CTAs: "Create your first automation" → the Automations
     quickstart; "Start working with Coworker" → fresh session with the session-settings
     panel open (§24's teach-by-landing kept). The gallery card + scope line stay hidden.
- **The Automations quickstart = ONE template system** (`AutomationQuickstart.tsx`): the role
  recipes (GitHub digest / Pipeline digest / Morning brief) merge into "Start from a template"
  beside the generic cards (news / inbox digest / folder cleanup — Inbox digest now names its
  Gmail dependency). Cards are equal-height and carry §27's connector-dot vocabulary (brand =
  connected, grayscale = not; "No connections needed" cards say so). Picking a card expands
  the configure card: connect rows (lazy cloud sign-in pane, GitHub link-existing escape
  hatch), channel-by-name, day × time, §25 consent for write recipes / read disclosure
  otherwise, and a named-gate Create button ("Connect X to continue" / "Pick a channel…").
  Shown on the empty state and alongside "+ New automation".
- **Decided along the way:** the §27 start-screen GitHub→Slack row keeps its prefill contract
  (the agent path stays first-class); the quickstart is the click-path twin.

## 30. Quickstart connect polish: configure header, honest connect states, branded loopback pages  *(Decided 2026-07-13 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-011-quickstart-connect-polish.html`; ledger UX-011)*

Three fixes from the owner's DMG #10 walkthrough of the §29 quickstart.

- **The configure card opens with a header** — kicker `SET UP` + the template's title + a
  cadence echo ("Connections, delivery & schedule · Weekly") as one bordered row inside the
  card — so it visibly belongs to the picked template instead of starting abruptly after the
  grid. Picking a template also scrolls the card into view.
- **Connect gets honest states, inline (owner offered a modal; inline won** — the connect
  completes out-of-band and the page isn't actually blocked, so the row narrates itself):
  `Connect` → `⟳ Opening browser…` (the broker POST is in flight; this covers the 4–5 s of
  dead air) → `⟳ Waiting for <Tool>…` plus a handoff strip under the row ("Finish connecting
  <Tool> in your browser. Approve it there, then come back — this page updates by itself."
  with **Cancel**). Cancel clears only the local waiting state — the browser tab is the
  user's to close; the existing poll flips the row to ✓. The same states apply to
  "Sign in to OpenCoworker Cloud" in the quickstart's lazy pane and on onboarding page 2.
- **The loopback pages become one branded card** (`_browser_page` in the sidecar): OCW mark,
  ok/fail icon (the connector's badge rides the ✓), Title-cased connector names ("Slack
  connected", never "slack connected"), "You can close this tab and return to OpenCoworker",
  the error detail preserved on failures (it's the debugging breadcrumb), and a "Served
  locally by OpenCoworker on your Mac" footer. Inline CSS, light/dark via
  `prefers-color-scheme`, zero external assets — the page must render offline.
- **Out of scope (broker slice, tracked separately):** the "Already installed on GitHub?
  Link it ›" escape hatch and the GitHub install-page dead-end — properly fixed by
  authorize-first connect + a `GET /me/connections` restore in ocw-connect.

## 31. Slack mention router: @ocw spawns a thread-scoped coworker  *(Decided 2026-07-13 by owner; ledger UX-013)*

Mentions are the PRIMARY Slack entry point ("very few people will do a subscribe inside an
agent" — owner). Before this, an @ocw tag in a channel with no subscribed session was silently
dropped.

- **Tag in an unsubscribed channel → a NEW coworker session per THREAD**, replying into the
  thread (a top-level tag threads on its own message, Slack-style). Follow-up tags in the same
  thread STEER the same session — dedupe keys on the thread target
  (`slack:T…/C…:thread_ts`), persisted in `mention_threads.json`. A deleted session releases
  its threads; the next tag spawns fresh.
- **The thread is pre-approved, nothing else is:** the spawned session carries a standing
  `send_message` grant (§25 shape, exact-target match) for its origin thread only — the
  conversation never stalls on an approval nobody in Slack can see. Any other action asks as
  usual (approvals park to the Inbox, §28). The grant is re-derived from the durable map on
  every engine rebuild, so it survives restarts.
- **A user-connected coworker overrides the router:** a subscribed session gets the tag with
  must-respond-in-thread framing; the router spawns nothing there.
- **Chattiness tiers:** tagged → must respond, in the thread · untagged channel traffic →
  judgement-only, "stay silent unless a reply adds real value" · the allow-list still gates
  everything upstream (unauthorized senders park, never spawn).
- **Sidebar:** mention-spawned sessions carry `origin`/`origin_label` and gather in ONE
  cross-persona **"From Slack (N)"** band — collapsed by default, both layouts, placed after
  Pinned; rows wear the Slack logo right-aligned in the indicator cluster. They never
  duplicate into Recent or the persona lists (pinned wins; archived keeps the persona
  disclosure). **No auto-archive** — deferred to a future global settings page (default vs
  Slack session policies, owner call 2026-07-13).
- `↪ Refinement (owner, 2026-07-14):` **titles put the ASK first** — "{ask, 48 chars} —
  #channel", mention token stripped. The old "#general — <@UBOT> …" prefix made every
  mention session truncate identically in the sidebar; the ask is what varies, so it gets
  the budget, and origin is already told three ways (group, icon, origin_label).

## 32. One session panel: the rail absorbs the Sources drawer  *(Decided 2026-07-13 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-014-session-panel.html`; ledger UX-014)*

Progress (doing) · Artifacts (made) · Access (may touch) are three views of ONE session, so they
share one panel and one entry — the existing panel toggle. Retires §23's rest/hover/click row,
its glance machinery, and the overlay drawer.

- **The Access section** is the former Session-settings drawer recut for rail width: Sources
  with per-session mute toggles (and the two-way connectors' channels drill-down as an inline
  child view), Recommended with connect-in-context, Folders (roots + RO/RW gate + branch), and
  the global-connectors link. No second overlay, ever.
- **The header carries the trust glance permanently** — "Access · Slack, GitHub · 2 folders"
  (first two live source names +N, then the folder fact; project-scoped sessions name the
  folder). Ships collapsed; nudge TEXT still never renders at rest (§23's rule, carried over).
- **The rail renders for every non-chat persona** (code/ops had the drawer but no rail).
  Sections per family — cowork: Progress · Artifacts · Access; code-family: Progress · Access.
  `↪ Agreed follow-up (owner, 2026-07-13, NOT built):` code-family later gets **Files** in the
  middle slot instead of Artifacts — a changed-files view (PR-style ±), repo-native where
  Artifacts is deliverable-native.
- **Deep links:** the intro's "Configure ›" and onboarding's "Start working" open the rail with
  Access expanded (scrolled into view). The topbar nets minus one icon.
- `↪ Addendum (owner ask, same day):` **"+ Add a source…"** — the catalog's long tail,
  in-session. A quiet row under Sources becomes a typeahead over the full connector catalog
  (available + not already connected, capped at 6); picking one enters the SAME
  connect-in-context child view, and a source added from a session is also enabled for that
  session on connect. Search-only, never a browsable list — browsing stays on the global
  Connectors page. The child view states the scope rule once: connecting is account-level,
  the toggle is per-session.
- `↪ Refinement (owner ask, same day):` **Folders mirrors Sources** — the old drawer's card
  wrapper and dashed add-button read too heavy in the rail. Folder rows sit flat under the
  FOLDERS header (same left edge and rhythm as source rows) and the add affordance is a quiet
  "+ Give access to a folder…" link, twin to "+ Add a source…", expanding the same inline
  RO/RW-gated form in place. The whole section scans as one repeated pattern: uppercase
  header → flat rows → quiet "+" line. Collapsed rail sections also equalized (headers share
  a 24px min-height so Artifacts' action buttons no longer make it the tall one).

## 33. Tool calls read as English; the TURN is the group  *(Decided 2026-07-13 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-015-tool-call-lines.html`; ledger UX-015)*

The old step group was raw plumbing: mono tool names + args + JSON result boxes, a separate
teal box per resolved approval, and an approval count advertised on the collapsed line.
Replaced wholesale — three layers, all shipped together:

- **One English line per tool call, synthesized client-side** (`humanize.ts`): the model does
  NOT emit a purpose per call, so per-tool templates turn name+args into a sentence — "Sent a
  Slack message to …", "Updated the plan — '…' → done", "Read runbook.md". `run_shell` is the
  exception and the model DOES speak there: its `description` argument (model-written intent,
  §25 lineage) is preferred — "Ran `git log …` — list yesterday's merges". Unknown/long-tail
  tools fall back to "Used *tool* — *short args*".
- **Approvals fold into their tool's row as a chip** — ✓ approved / auto-allowed (standing
  rule; tooltip names the automation) / ✕ declined. A declined ask has no executed call, so it
  keeps its own row, phrased as intent: "Wanted to run `rm -rf build/` — ✕ declined". No
  separate approval boxes, and no approval count on the collapsed line: only a DECLINE and the
  privacy note ("N hidden by your filters") may surface at rest.
- **Narration + turn-level grouping:** the engine prompt (all personas) asks for ONE short
  status line before each batch of tool calls. Grouping therefore moves up a level — the whole
  user-message → final-answer span collapses as one turn ("N steps"), with quiet narration
  lines (assistant text followed by more activity in the same turn — pure client-side
  classification) interleaved between humanized step rows. The final answer stays a normal
  bubble OUTSIDE the disclosure. A running turn is open and live (spinner on the current
  step, "Running N steps…"), and collapses when the answer lands unless the user pinned it
  open. Non-narrating models degrade to a turn with no quiet lines.
- **Raw is demoted, not deleted:** hover a row → "raw" → verbatim `name + args → result`
  block, for debugging.
- `↪ Refinement (owner report, same day — the intermediate-bubble flicker):` while the session
  is LIVE, the final run's trailing assistant text is still narration, never promoted to an
  answer bubble — each status line was flashing as a full ASSISTANT bubble and then vanishing
  into the group when the next tool call arrived. The answer bubble now appears exactly once,
  when the turn ends (Transcript takes the session's `running` flag). Streamed text mid-turn
  renders as the same quiet line (no ASSISTANT chrome); a stream straight after the user's
  message keeps the bubble (plain reply). PENDING approvals/questions no longer split the run
  (they render in the composer, not the transcript), so waiting on a decision doesn't
  reshuffle the story. A collapsed live turn carries its latest narration on the header —
  "Running 10 steps… · *I'm creating a generator that embeds…*" — so the pulse never hides.
- `↪ Refinement #2 (owner, 2026-07-14 — the stream gate):` turn-START streams are HELD
  (spinner shows) until they either get a tool call — narration; renders complete as the
  quiet line, a bubble never appears — or cross **40 words** with no tool call — the answer;
  the bubble starts streaming from that point. A message that ends under the threshold with
  no tools shows its bubble at completion (~a second late, owner-accepted: "people can wait
  1-2 seconds longer").
- `↪ Refinement #3 (owner, same day — #2 only covered turn start):` mid-turn narration still
  painted as a floating full-size paragraph (a `.md` CSS override compounded it). ONE rule
  now for all streamed text: under 40 words it belongs to the LIVE turn group — on the
  collapsed header ("Running 12 steps… · checking the historical pages…") or as the small
  quiet line when expanded — and 40+ promotes to the streaming answer bubble, start and
  mid-turn alike. **Turns also start COLLAPSED while running** (owner call): the header's
  live line is the pulse; expanding is opt-in. Residual unchanged: 40+ words of narration
  degrades to a bubble.
- `↪ Later (not built):` turn duration in the header ("Worked for 1m 04s") once transcript
  messages carry timestamps; an optional model-written `purpose` on more tools if the
  fallback lines feel dry.

## 34. Artifacts come to the user  *(Decided 2026-07-14 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-016-artifact-delivery.html`; ledger UX-016)*

Finishing a task must not strand the deliverable behind the Artifacts panel — the agent hands
it over, in the GUI and in Slack.

- **`artifact:` links (GUI):** the agent ends a deliverable turn with plain markdown —
  `[Title](artifact:relative/path)` — and the renderer shows an artifact CHIP (icon, title,
  filename, "Open ›") that opens the existing rail viewer in place (un-hiding the rail if
  needed; path resolved against the session's artifact list, one refresh on a miss).
  Rejected: a custom `<ocw:artifact/>` tag (models mangle XML; other surfaces show it raw)
  and a `show_artifact()` tool (extra round-trip, forgotten, imperative-UI-on-replay).
  Prompted in the Cowork instructions; the scheme survives the markdown sanitizer via an
  explicit urlTransform carve-out.
- **`send_file` tool (Slack):** uploads a workspace file into the thread/channel
  (`files_upload_v2`). Slack renders its own previews for pdf/csv/images — send the FILE,
  not a thumbnail. Same target grammar and token resolution as send_message (desktop-direct
  even on the managed relay — the per-team bot token is local), but a DIFFERENT tool name:
  a mention-thread's standing `send_message` grant never covers uploads (task_rules key on
  the tool name; pinned by test). Paths must resolve inside the session's workspace/roots;
  50 MB cap.
- **HTML → screenshot:** the one format Slack can't preview. `send_file(as_screenshot=true)`
  renders the local page headless (the Playwright we already ship, 1280×800 viewport) and
  uploads `<stem>.png` instead. HTML-only by design.
- **Scopes:** managed bot scopes gain `files:write` (broker providers.py — new connects
  request it, existing workspaces re-consent on their next Connect); the manual Socket-Mode
  instructions list it; the hosted app's Bot Token Scopes must match (dashboard).
- `↪ Queued (owner):` artifact SHARING — public / select emails, behind OpenCoworker Cloud
  sign-in. Separate decision; discussion next.

## 35. Approval cards speak the transcript's language  *(Decided 2026-07-14 by owner; mock: `ocw-context/docs/ux-improvements/mocks/UX-018-approval-card.html` (v3); ledger UX-018)*

The card shouted "PERMISSION REQUIRED" over a mono tool chip and a raw args dump while
§33/§34 made everything around it read as English. Replaced:

- **Humanized headline** (`humanizeApprovalTitle`): "Write `fetch_data.py`", "Send a file to
  **#general**", "Run a command — fetch semiconductor stock data" (run_shell leads with the
  model's own description). The tool name demotes to the shield icon's hover.
- **Risk-tiered density:** routine workspace writes (write/edit/patch) render as a compact
  ROW — title, inline `preview ▾`, Allow / Always allow / quiet Deny. Everything else is a
  full card; actions that LEAVE the Mac (send_message/send_file/connector tools) wear a warm
  border and an explicit scope note ("leaves this Mac → Slack") where the "local action"
  badge used to sit. Locals say "stays on this Mac" (+ "overwrites the existing file" when
  true). §25 consent cards (create_scheduled_task grants) always keep the full card.
- **Previews are the PROPOSAL, from the tool call's args** — the file/action doesn't exist
  yet, so no viewer could show it (the code-family "Files" view is post-hoc; §32 follow-up).
  File writes: first 5 lines + "show all N lines". Shell: the command block. Sends: the
  rendered message / file chip. Long-tail tools keep the compact args line.
- **Buttons:** no solid fills — the primary action is a blue border. Workspace-local
  "Always allow" stays short (the honest rule — "always allow X for this session" — on
  hover). run_shell offers ONLY the command-scoped "Always allow this command" (two adjacent
  always-buttons blur scope — §25's own argument). "Allow every time" (run contexts, §25)
  unchanged. Deny is quiet text; red only on hover.
- `↪ Deviation from mock v2 (honesty over label):` the external card's "Always allow sending
  files to #general" is NOT built — outside run contexts the only mintable grant is
  session+tool-wide, and a target-named label would overpromise. External cards show the
  short "Always allow" with the honest hover. Target-scoped standing rules for plain
  sessions = future permissions work if wanted.
- `↪ Addendum (owner catch, same day):` **parked approvals wear the same dress.** A reopened
  session renders its pending approval from the Inbox (InboxItemCard), which still spoke the
  old dialect — "APPROVAL / Run `browser_read_url`? / solid-blue Approve". Parked items now
  always carry `tool` + `arguments` in their data, and the card reuses the §35 pieces
  (humanized title, scope note, content/command preview, blue-border "Allow once", quiet
  Deny). Resolution vocabulary unchanged (works on every approver path); pre-§35 rows
  without tool data keep the legacy treatment.

## 36. Connector reads never gate; channel names just work  *(Decided 2026-07-14 by owner; no mock — behavior, not layout)*

Two trust-model fixes from live use, one principle: connecting a service IS the consent for
reading it; writes are where blast radius lives.

- **Reads never gate.** The tool registry's read/write kind (`tool_defs.py`) is now LAW for
  every registered connector tool: reads (`github_search`, `gmail_search_messages`,
  `browser_read_url`, `browser_open_url`, …) carry `requires_approval=False`; writes
  (`gmail_send_email`, `github_create_issue`, `discord_send_message`, …) always gate. The
  §25 design note ("reads never gate, a rule would be meaningless") had never actually been
  wired — connector tools inherited the conservative-MCP default, so a weekly GitHub digest
  paused THREE times for a human to approve *searching GitHub*, while the quickstart copy
  promised "reading never needs approval". Call-site flags now govern only tools without a
  registry entry (MCP/experimental stay conservative). Debatable-and-decided: registry
  classifies `browser_open_url` and `github_clone/pull` as reads — they ride the rule.
- **"Post Hi to #general" works.** `send_message`/`send_file` accept a Slack channel NAME
  (lowercase or `#`-prefixed — Slack names are strictly lowercase, ids are uppercase
  `C…/T…/…`, so the shapes never collide) and resolve it through the same cached
  `conversations.list` roster the GUI's channel picker uses: exactly one match across
  connected workspaces → team-qualified address → the right per-team token. None/many/
  not-a-member return actionable errors ("invite @ocw to #private-ops in Slack, then
  retry") instead of the misleading "no bot token for slack" the owner hit — that error
  came from the token resolver falling back to the relay's marker profile when a bare name
  carried no team prefix. `not_in_channel` from Slack maps to the invite hint too.
- `↪ Open question (owner, parked):` platform-specific tools (`send_slack_message`) instead
  of the generic `send_message`? Revisit.

## 37. Voice input is installed, checked, and tested in Settings before the composer enables it  *(Decided 2026-07-15 by owner → Built 2026-07-15; mocks: `ui-mocks/voice-input-settings.html`, `ui-mocks/voice-input-composer-states.html`)*

Pressing a microphone is an intent to speak, not consent to begin a 142 MiB download. Voice input
therefore has an explicit setup home and a visible readiness contract before it enters the composer.

- **One setup surface:** Settings gains **Voice input** as a first-class section (not under Models —
  provider/model selection and a local input capability are different mental models). It explains
  that transcription stays on-device, names the installed engine/model, shows disk use, and owns
  Download / Cancel / Repair / Delete / Test again. The composer never downloads a model.
- **Honest state machine:** `unsupported → not installed → downloading → verifying → ready`, with
  `needs microphone permission`, `test required`, and `error` as actionable substates. Download
  progress shows transferred/total bytes; completion verifies the expected size + SHA-256 before
  the partial file is promoted. A failed/interrupted download stays non-ready and can be restarted
  (transfers begin from zero; a resumable Range request is a possible later refinement).
- **Test before enablement:** after installation, Settings asks the user to record one short phrase,
  displays the local transcript, and marks Voice input Ready only after a successful non-empty test.
  The test is also where macOS/Windows microphone consent is requested. Readiness is invalidated if
  the model disappears/fails verification or microphone permission is revoked; users can Test again.
- **Composer contract:** until Ready, the mic is visually muted and exposes **“Configure Voice Input
  in Settings”** on hover/focus. It remains keyboard-focusable (`aria-disabled`, not a dead native
  disabled button); activation deep-links to Settings ▸ Voice input. Once Ready it is a normal
  click-to-record / click-to-stop control and inserts an editable transcript — never auto-sends.
- **Four composer states:** (1) **Ready at rest** — enabled mic in the normal control row; (2)
  **Listening** — clicking the mic replaces the quiet middle controls with a live waveform, elapsed
  time, and an unambiguous square Stop control; attachment stays available; (3) **Transcribing** —
  a deliberately lightweight, often-fleeting state after Stop, with provisional text in the draft,
  a busy mic treatment, and Send protected until finalization; (4) **Draft inserted** — the mic and
  normal controls return, the transcript is ordinary editable composer text, and the user decides
  whether to edit or send. The waveform is decorative (`aria-hidden`); state changes are announced
  through one polite live region for assistive technology.
- **Model control invariant still applies (§17/§22):** the four-state mock depicts an existing
  conversation, so it intentionally has no model name/picker in the composer. The picker appears
  only before the first turn; afterward the fixed model is a header fact, never a composer control.
- **Hard compatibility gate = shipped binaries, not aspirational library support.** Initial support:
  Apple Silicon Mac (M1+) on macOS 12+; x64 PC on Windows 10 22H2 or Windows 11. The native layer
  reports OS + architecture and the Settings page shows the failed requirement in plain language.
  Intel Mac and Windows ARM remain unsupported until the release pipeline produces and tests those
  artifacts. RAM/core count are performance signals, not deterministic blocks: show **8 GB RAM and
  4 CPU cores recommended** plus “transcription may be slower” when below them, then let the test be
  the truth.
- **Recovery and storage:** the card always shows installed size without exposing a noisy filesystem
  path. Delete model reclaims the space and returns to Not installed; Repair re-downloads and
  verifies; app upgrades do not silently replace a working model.
- `↪ Supersedes the first STT cut:` the composer currently downloads `ggml-base.en.bin` on first mic
  click. That behavior is deliberately removed; the reusable local STT library and editable-draft
  behavior stay.

## 38. Pre-connect connector detail page: About, honest Access bullets, tools behind a disclosure  *(Decided 2026-07-18 by owner; no mock — extends §21's grammar)*

Prompted by competitor plugin-gallery pages: before any credentials exist, an **available**
connector's row navigates to a detail subpage (same `‹ Connectors` breadcrumb as §21) so the user
can see what they're granting before the vendor's consent screen does. The list's Connect pill
remains the fast path straight into the add-modal.

- **Content, top to bottom:** header (badge / title / blurb subtitle / Connect pill) → optional
  About paragraph → **Access** group → collapsed **Tools** disclosure. Copy lives server-side
  (`catalog_copy.py`, served on `/v1/connectors`) so all surfaces share it.
- **Access bullets are honest or absent:** short statements of actual behavior — write powers name
  themselves ("Sends email as you"), reads state their boundary ("…your account can see"), negative
  guarantees are explicit ("Never deletes mail"). Every bullet must stay true to the connector's
  real tools and, for managed connectors, the cloud app's scopes — overclaiming here is a product
  bug (test-enforced: every available connector ships curated bullets). A standing footnote carries
  the platform guarantee: "Keys and tokens are stored only on this computer."
- **Tools are a collapsed "N tools this connector adds" disclosure** — advanced-reader detail, no
  enable/disable pre-connect (that lever exists on the connected page, §21); write tools carry the
  "asks first" tag. Owner: listing is enough, toggles are not needed here.
- **No third-party-risk interstitial, deliberately:** competitor consent modals ("apps may introduce
  elevated risk", data-sharing disclosures) exist because their plugins are third-party code
  receiving conversation context. Our catalog is first-party tools with local-only tokens — copying
  that fear-language would import a risk we don't have. The Access section IS our disclosure.
- **Connect completes in place:** the poll flips `connected` and the same route re-renders as the
  connected detail page — no navigation cliff after OAuth returns.

## 39. Onboarding galleries: provider cards, then a two-state tools page  *(Decided 2026-07-18 by owner → Built 2026-07-18; mocks: `ocw-context/docs/ux-improvements/mocks/UX-019v2-onboarding-provider-gallery.html`, `UX-020-onboarding-tools-page.html`; ledger UX-019/UX-020)*

Owner: step 1 was "bland, tasteless and non-informing." Both onboarding steps become card
galleries that carry the product's breadth on their face, under one frame rule: **the header
and footer never move — only the middle region swaps, at a fixed height** (extends the
2026-07-12 no-resize rule to in-step view changes).

- **Step 1 = provider gallery.** All 13 providers as 2-per-row cards with their official brand
  marks (vendored from MIT-licensed lobe-icons into `src/providers/logos/`, always on a light
  chip so multicolor marks survive dark mode). Every card wears its own state — ✓ Connected /
  Not set up / No key needed — so multi-provider state costs zero clicks (closes UX-019's
  question). Recognition-first order; the long tail scrolls.
- **Card → key form, in place.** Breadcrumb + identity row, then the provider's real fields.
  **A passing Test verifies, SAVES, and auto-returns** to the gallery (~0.9 s, after the
  in-field confirmation registers) — the "extra click back" never exists on the happy path.
  State lives IN the field: green border + "✓ Tested & saved" pill (revisits show the same;
  typing clears it); **no status lines below the form**. `base_url` on a keyed provider is a
  quiet "Custom endpoint ⌄" disclosure with **no explainer copy** (its users know what it's
  for). Ollama renders endpoint + **Detect**; a pass counts toward Next (configured-but-
  unproven keyless providers don't).
- **Footer: plain "Next"** (never "Continue with X" — configuring a provider isn't choosing a
  model; §17's per-session choice happens later). Next arms at ≥1 ready provider; from a dirty
  form it auto-verifies+saves first (preserves the 2026-07-12 no-hidden-two-step rule).
  **No accent-blue links anywhere** — crumbs/disclosures/help links are muted ink with quiet
  underlines (owner: blue links are "outdated design").
- **Step 2 = two-state tools page.** Headline "Connect your everyday tools" + the why
  paragraph ("A coworker that can only chat can only advise…"). Pre-sign-in: tool-logo row,
  "One click, keys handled" card, skip renamed **"Skip — I'll use my own API tokens"**, privacy
  line at the account-ask moment. Post-sign-in the SAME region becomes a **mini connector
  gallery** — the five managed connectors with live prod OAuth apps (Outlook · Slack · GitHub ·
  Notion · HubSpot); a card click launches the real one-click consent ("Check your browser…" →
  poll → ✓). This keeps the headline's promise on-page, resolving §29's
  promise-with-no-action concern. **Gmail + Google Calendar ship grayed "Coming soon"**
  (both gated on Google verification/CASA — flip `TOOLS_SOON` when it lands). One pending
  connect at a time (a second click quietly resets the first); failures reset silently — no
  error walls in onboarding; **Next stays armed throughout** (connecting zero tools is fine);
  HubSpot connects read-only here (least privilege; write is a Connectors-page consent).
  AWS Bedrock / Azure deferred: provider-layer auth work, and Azure OpenAI is already
  reachable via OpenAI's custom endpoint.

## 40. Settings: three tabs, and Models is the shared provider gallery  *(Decided 2026-07-19 by owner → Built 2026-07-19; mock: `ocw-context/docs/ux-improvements/mocks/UX-021-settings-redesign.html`; ledger UX-021)*

- **Three tabs — General · Models · Voice input.** "Appearance" was carrying seven cards, most
  of them not appearance; it renames to **General** (the tab *key* stays `appearance` so
  deep-links keep working). **Files folds into General as one card** — a single option doesn't
  earn a nav entry. **Personas is launch-flagged off** (`flags.ts` `showPersonas()`, default
  false; the e2e suite re-enables via localStorage to keep the hidden flows covered).
- **Models = the §39 gallery, shared for real.** The provider gallery ⇄ key form was extracted
  to `providers/ProviderSetup.tsx` (hook + `ProviderCards`/`ProviderForm`); Onboarding step 1
  and Settings ▸ Models render the SAME components with different frames, so the two surfaces
  cannot drift. Settings passes testid prefix `set-`, onboarding keeps `ob-`.
- **Settings-only additions:** configured cards carry "✓ Connected · used Nh ago" (truncating,
  never wrapping); the form of a credentialed provider gains a quiet red **"Remove key…"**
  (confirm → `DELETE /v1/providers/{name}` forgets the stored profile; the card reverts to
  "Not set up"; curated models stay, they just gray out).
- **Below the gallery:** the **"In the composer's picker"** card lists every curated model
  across providers with provider tags + the default badge (untick removes; adding happens from
  a provider's card, whose form view keeps the per-provider ModelChecklist or, unconfigured,
  the read-only "Included models" preview). **Token savings moved here from Appearance** —
  it's model-spend behavior. The gallery⇄form swap happens in place and the page scrolls; the
  fixed-height rule is onboarding-modal-specific.
- The old dropdown provider picker, the API/Local sub-tabs, and the separate Save button are
  retired — Test = verify + save + slide home, exactly as in onboarding.

## 41. Onboarding tools page: benefit rows, a band that never moves  *(Decided 2026-07-19 by owner → Built 2026-07-19; mocks: `ocw-context/docs/ux-improvements/mocks/UX-022b-tools-page-redesign.html` (3 directions), `UX-022c-benefit-rows-connect.html` (chosen); ledger UX-022)*

- **Benefit rows replace the card gallery** (supersedes §39's step-2 body; frame rules stand).
  Six rows — *Stay on top of email (Outlook) · Keep up with Slack · Ship code (GitHub) · Keep
  your notes in reach (Notion) · Keep the CRM current (HubSpot) · Track every relationship
  (Attio)* — benefit first, tool named in the ONE-LINE detail (wrap made row heights jump
  between states). Rows scroll past the fold; accepted ("let users scroll"). The gated Google
  pair is ONE combined grayed row with a static "Coming soon" — hover-only labels read as
  broken cards.
- **Zero layout shift at sign-in.** The band below the rows is pinned outside the scroll area
  and its slot never moves: pre-sign-in it carries the ask ("Sign in for one-click connections —
  OpenWorker handles the OAuth for 20+ tools… Tokens stay on this Mac") with the page's ONE
  black button; after sign-in the same slot turns green-congrats ("🎉 You're signed in as … —
  connect a tool above with one click, or add them anytime later"), and every row grows a quiet
  bordered **Connect** pill in place (Connect → "Check your browser…" → ✓ Connected; one in
  flight, silent resets — §39's connect rules carry over).
- **One footer button, one slot:** quiet bordered "Continue without sign-in" pre-sign-in →
  black "Next" after. The left "Skip" link is gone. The footnote is STATIC across states
  ("30+ more tools on the Connectors page — add or remove anytime. Tokens stay on this Mac"),
  so nothing below the band ever changes.
- **Modal is 560px** (down from 700) across all steps — the §39 fixed-frame rule holds; the
  provider gallery scrolls ~4.5 rows and the form views stop floating over a void. On step 1,
  **"Custom endpoint ⌄" renders below the key-help line** as its own advanced row (both
  surfaces — the disclosure lives in the shared ProviderForm).

## 42. Connectors are backing-agnostic: hosted-MCP connectors with a pinned tool allowlist  *(Decided 2026-07-19 by owner → Built 2026-07-19; no mock — extends §21's grammar; monday.com / Asana / Jira first)*

The user's decision: "leverage MCP+OAuth wherever possible, but still show it in the
Connectors section — from a UX perspective it shouldn't matter whether the connector is an
app or MCP." A connector is a product concept (card, copy, consent, approval posture); the
backing (broker OAuth app, vendor-hosted MCP server, manual token, relay) is plumbing.

- **MCP-backed connector** = descriptor with `mcp_url` + a **pinned allowlist** of tools in
  `tool_defs` (names `mcp__<connector>__<vendor tool>`). The pin is a whitelist that FAILS
  CLOSED: only pinned tools ever reach a session or the Access bullets — the vendor shipping
  new tools cannot silently expand agent capability (the owner's drift worry). Missing pins
  degrade; unknown tools never load (`include_tools` on the seeded server config, re-derived
  from the pin at session build so stale config can't widen it).
- **Connect is one click and FULLY LOCAL**: the sidecar runs the MCP OAuth 2.1 flow (DCR —
  no client secret, no broker, no OpenWorker sign-in). The modal says so ("no OpenWorker
  account needed; sign-in runs entirely on this computer"). Connectors with a manual path
  too (jira, asana) keep the §21 One click | Manual pills; MCP-only connectors (monday)
  render the one-click pane directly. The profile's `mode: "mcp"` decides which tool set is
  live — MCP pin vs the manual REST tools — one at a time, never both.
- **Per-tool approval follows the pin's read/write classification** (reads run free, writes
  ask first), not the MCP layer's server-level flag; unclassified defaults to ask. Session
  gating matches connector tools exactly: effective-connector set (§4.3) + per-tool toggles.
- **Placement**: MCP-backed connectors are cards on the Connectors page with §38 detail
  pages; they are HIDDEN from the Settings ▸ MCP tab (that tab stays for arbitrary/community
  servers). Disconnect forgets tokens + DCR registration + the seeded config.
- First wave: **monday.com** (`mcp.monday.com/mcp`, MCP-only) and **Jira** via the
  Atlassian server (`mcp.atlassian.com/v1/mcp`, + manual API token) — both accept DCR, so
  no vendor app registrations, no review queues, no broker changes. **Asana was pulled
  from the wave on 2026-07-20** (live test): their V2 server rejects DCR and requires a
  pre-registered "MCP app" with an exact-match redirect URI, which the desktop's
  dynamically-chosen sidecar port can't satisfy — one-click returns via a broker-routed
  callback later; its pinned defs sit dormant and the manual PAT path stays.

## Change log (requests, newest first)

- **2026-07-19 (26)** — Owner: "leverage MCP+OAuth wherever possible but still add it in the
  Connectors section — UX-wise it shouldn't matter if the connector is an App or MCP"; and
  "selectively allow only certain tool calls from the MCP connectors" (drift worry: vendors
  changing their exposed functions) → §42 (pinned-allowlist MCP-backed connectors; monday.com
  + Asana + Jira built same day). Tool-def scaling (caching audit, deferred tools + search)
  noted on the launch punchlist for a later discussion.
- **2026-07-19 (25)** — Owner critique of the built §39 flow (from DMG walkthrough screenshots):
  hover-only Coming soon, scattered grayed cards, blue-vs-black primaries, 700px void, key
  state-restore bug, endpoint placement → fixes + full step-2 redesign through three mock
  directions and two refinement rounds (his idea: rows grow Connect buttons; band stays with
  congrats content) → §41 (mocked UX-022b/c; built 2026-07-19).
- **2026-07-19 (24)** — Owner: "Settings is quite a mess visually" (token savings under
  Appearance, one-option Files tab) + "Models should follow the onboarding look" + hide
  Personas behind a flag + collapsed-nav overlap fix → §40 (mocked UX-021; built 2026-07-19).
- **2026-07-18 (23)** — Owner: onboarding step 1 "bland, tasteless and non-informing" →
  provider gallery; three review iterations (fixed frame; in-field saved state + endpoint
  disclosure + "Next"; de-blued links); then step 2's why-paragraph + post-sign-in connector
  gallery ("It's tempting!") → §39 (mocked UX-019v2/UX-020; built 2026-07-18).
- **2026-07-18 (22)** — Owner (from competitor plugin-gallery screenshots): ship a pre-connect
  connector detail page with launch — full details up front, tool list behind a link ("a detail
  that only the advanced user will care" about), no per-tool toggles → §38.
- **2026-07-15 (21)** — Owner: no download at the moment of use; put Voice input setup,
  compatibility requirements, visible model progress, verification, and a microphone transcript
  test in Settings; keep the composer mic muted with a Settings deep-link until ready → §37
  (mocked in `ui-mocks/voice-input-settings.html` and `ui-mocks/voice-input-composer-states.html`;
  built 2026-07-15). Owner follow-up added the four composer states from ready → listening waveform →
  transcribing → editable draft.
- **2026-07-14 (20)** — Owner ("make connector reads free" + the failed "post Hi to
  #all-opencoworker" repro): registry-kind-driven approvals + Slack channel-name resolution
  → §36.
- **2026-07-14 (19)** — Owner: approval box "needs to be more modern" (+ stream-gate ask,
  §33 ref #2) → §35 (drafted UX-018, mock v1→v3 with owner review — no row icon, short
  Always allow, blue-border buttons, inline preview state — graduated same day).
- **2026-07-14 (18)** — Owner: "should we give it a way to bring up the artifact?" + Slack
  thumbnails → §34 (drafted UX-016, built P1/P2/P3 same day; markdown-link scheme over
  tag/tool; post-the-file over thumbnails; HTML screenshot via the shipped Playwright).
- **2026-07-13 (17)** — Owner (DMG screenshots + the Codex-transcript discussion): tool calls
  as English one-liners, approvals folded into the row, no approval count at rest; narration
  folded in after the "do you inject a purpose?" exchange → §33 (drafted UX-015, mock v2,
  graduated same day).
- **2026-07-13 (16)** — Owner: merge the Sources drawer into the Progress+Artifacts rail and
  drop the topbar settings icon → §32 (drafted UX-014, graduated same day; §23 retired with an
  amendment note; code-family rail = Progress · Access, "Files" replacing Artifacts recorded as
  the agreed follow-up).
- **2026-07-13 (15)** — Owner (Slack notes, discussed then planned): @ocw tags spawn
  thread-scoped coworkers replying in-thread; connected coworkers override; silence-default
  chattiness; collapsed "From Slack" sidebar group with a right-aligned platform icon → §31.
- **2026-07-13 (14)** — Owner (DMG #10 walkthrough, five findings on the Automations page):
  configure-card demarcation, connect-wait feedback, and beautiful loopback pages → §30
  (drafted UX-011, inline states over a modal, graduated same day). The two
  already-connected-detection findings became the broker slice (authorize-first +
  `GET /me/connections`), not a GUI spec item.
- **2026-07-12 (13)** — Owner: sign-in as onboarding page 2, CTA-only page 3, recipe out of
  onboarding ("Automations quickstart or onboarding?") → §29 (drafted UX-010, mock v2 with
  owner's manual-keys line + equal-height cards + real-logos rule; graduated same day).
- **2026-07-12 (12)** — Friend-install bug-report doc (9 items, via owner): §24 revisions —
  Continue on the model step verifies AUTOMATICALLY (fills-gated, "Checking…", error stays;
  Test becomes an optional explicit check) and the recipe's gated Create-automation button
  names its missing piece ("Pick a channel to post to first"). §3 revision — the drawer's
  persona why-connect pane is HIDDEN for now (no multiple-persona mentions until personas
  relaunch; `SHOW_PERSONA_PANE` flag restores it). Plus non-spec fixes: agent folder-scope
  guardrail + Info.plist usage strings (macOS permission prompts), the double-ask approval
  flash, artifact Copy-path copies the absolute path. See bugfix-ledger 2026-07-12.
- **2026-07-12 (11)** — Owner (walkthrough, simplification pass on onboarding → §24 revision):
  key fields lose the "Stored locally (0600)" help line (manual territory, removed at the
  provider registry so Settings ▸ Models loses it too); OpenAI's optional endpoint joins the
  "Configure custom endpoint ›" disclosure (ANY base_url on a keyed provider collapses now,
  not just defaulted ones); the success state moves ONTO the Test button ("✓ Connected" —
  the status line keeps only errors); the done step HIDES the Specialist-coworkers gallery
  card (returns later; `finish("gallery")` plumbing kept) and drops the per-session-scope
  line; the modal height is FIXED at 700px across all three steps (sized to the tallest —
  the recipe at ~593px content — action rows pin to the bottom, overflow scrolls inside).
- **2026-07-12 (10)** — Owner (walkthrough, screenshots of three pages): Automations /
  Activity / Inbox must share one look; Messaging routing belongs in Inbox → §28 (one page
  shell everywhere; Inbox tabs Pending / Configure; Connectors sub-nav = Connectors · MCP).
- **2026-07-11 (9)** — Owner (walkthrough, step 2 recipe): the channel box must show the
  channel NAME, not `slack:T…/C…` — ChannelPicker now separates display (#name at rest,
  raw address while editing + in the tooltip) from the stored target, on BOTH its surfaces
  (onboarding + session channel subscriptions); the consent line uses the name too. The
  fixed day+time cadence pairs → a day SelectMenu (Mon–Sun, Weekdays, Every day) × a free
  time field; digest instructions re-worded cadence-neutral ("since the last digest") →
  §24 revision.
- **2026-07-11 (8)** — Owner (Mac-app onboarding walkthrough): step 1 warms up ("Welcome to
  OpenCoworker" + connect-to-get-started sub-line); native selects → SelectMenu (the Settings
  Models control); default-model picker dropped (per-session anyway; never persisted);
  optional endpoints behind "Configure custom endpoint ›"; Test joins the Skip/Continue
  action row → §24 revision.
- **2026-07-11 (7)** — Owner (ledger UX-008, mock approved): the §23 session-settings row
  docks into the topbar's left region — one bar instead of two strips; contract untouched
  (rest = icon · hover/focus = glance · click = drawer). Expanded nav: icon first after the
  panel edge; collapsed: after the §22 cluster → §22/§23 amendments.
- **2026-07-11 (6)** — Owner (visual pass on the new shell): topbar ⋮ conversation menu
  removed (nav row's hover cluster covers it; title kept — sole identifier when the nav is
  collapsed); topbar goes edgeless glass (border dropped, paper-tinted blur); composer Mode
  trigger goes borderless and names the chosen mode → §22 amendments.
- **2026-07-11 (5)** — Owner (competitor new-session comparison; ledger UX-007, mock v3
  approved): start screen → three concrete template tasks that carry their own setup (no icon
  tiles; outcome-voiced sub-lines with connector dots; ready = hover "Start →" + prefill,
  gated = always-visible "Configure ›" → the §23 drawer); "Set me up (optional)" removed;
  specialist entry point deferred to an owner sketch → §27. Also: ✳ mark → ✦ app-wide.
- **2026-07-11 (4)** — Owner (Settings audit; ledger UX-006, mock v2 approved): sidebar bottom
  → one account row (name + cloud status dot + state-driven sticky-unlock inbox chip);
  "Settings & more" retired; Integrations renamed Connectors and lives in the account menu with
  Inbox; the two same-named "Activity" pages deduped (dead-letter → Messaging routing ▸
  Unrouted; audit log keeps the name); "Approvals" rename rejected → §26. Supersedes §12's
  bottom cluster.
- **2026-07-11 (3)** — Owner (UX-005 design discussion): per-automation standing scoped
  approvals — tool+target+task rules on the ScheduledTask record, minted only at the creation
  consent card (agent proposes via a `permissions` field) or a run card's "Allow every time";
  agent-written config.toml rejected → §25. Unblocks §24's build.
- **2026-07-11 (2)** — Owner (boss-flow study: new install → recurring GitHub→Slack digest;
  ledger UX-004, mock v2 approved): onboarding restructured to model → role-tabbed recipe →
  tips → §24. Recipe consent = standing scoped approval (ledger UX-005, design pending; §24
  build blocked on it). Gallery-seeding-by-tab parked until after UI cleanup.
- **2026-07-11** — Owner (hand-drawn sketch → UX ledger `ocw-context/docs/ux-improvements/`,
  entries UX-002/UX-003, mocks reviewed + approved): session-screen cleanup — contextual
  `[sidebar][+][search]` cluster (collapsed-sidebar only), facts subtitle replacing the locked
  pill + persona button, composer reduced to `[+][Mode ⌄][send]` with Unattended folded into the
  Mode menu, fresh-only model chip → §22. SourcesBar → session-settings row (hover glance, click
  manage; gray icon = the nudge; drawer renamed "Session settings" + working directories) → §23,
  superseding §3's bar. Naming (ledger UX-001, partial): in-app nav noun = **"Coworkers"**;
  **"Specialist"** reserved for marketing/gallery voice; internals keep `persona`.
- **2026-07-08** — Owner, reviewing the M3.5 Slack-workspaces tab: wanted connector detail as a
  subpage under Connectors (not a nav item), connected-first Connectors list, Apple-quiet styling,
  one add-connection entry point (header-button modal with One click | Manual pills), collapsed
  Tools everywhere, and enterprise privacy levers (Gmail sender/label exclusions — drop silently,
  no `<truncated>` tombstone; HubSpot hidden fields + read-only connect). Multi-account confirmed
  feasible for Gmail (accounts), HubSpot (portals), Teams (orgs). Teams consent corrected: self-
  serve via user install/team-owner RSC, admin only if org policy blocks apps → §21. Build order:
  Connectors list + subpage nav → Slack page → Gmail multi-account → HubSpot → Teams.
- **2026-07-05 (2)** — Owner aesthetic asks on the left nav: floating/collapsible on demand
  (Claude/Codex-style), a RECENT header with a group+filter control moved off the top bar, and
  auto-collapse when an artifact opens → §20. Chose hover-peek + pin, and auto-collapse with
  auto-restore.
- **2026-07-05** — Owner (Slack-on-PM setup): double-send first-contact flow called clumsy →
  §19 (park + one-step allow-and-deliver, connector card as config surface, gateway
  hot-reload). Root-caused "Slack keeps resetting": pre-Jul-3 pytest runs clobbered real
  tokens with a test stub (`xoxb-1`) — isolation fixture already fixed it; proved with
  hash-compare across the full suite. Sender names showing "unknown" = missing bot scopes
  (users:read, channels:read) — setup instructions updated.
- **2026-07-04 (2)** — Owner (testing pass, persona disable): disabled personas kept their
  sidebar sections because old sessions held them (never-orphan rule). Discussed hide vs grey vs
  time-based liveness; owner picked **archive-all-on-disable** (§18) with an inline confirm at
  the disable click.
- **2026-07-04** — Owner: model-key testing pass. First-class provider entries for
  OpenAI-compatible vendors + Together/Fireworks resellers with a curated model matrix (ids →
  labels → capabilities); custom provider picker (sections, key-set dot, last-used); "chose Opus,
  Kimi replied" → model rides every message and is **fixed per session** (§17).
- **2026-07-03 (2)** — Owner testing pass found 10 issues; while fixing the project-workspace
  cluster, owner challenged the workspace enum ("family:knowledge means scratch; family:code means
  explicit directory — why more?"). Decided §16: collapse workspace into family. Also: session
  delete → archive-first with soft confirm + scratch cleanup; sidebar caps sessions per persona
  (configurable); connect-in-context from the session drawer; Inbox filters + orphan pruning.
- **2026-07-03** — Owner: Gallery UX rethink (§15): delete personas; Gallery behind a link as a
  full-screen **modal** (owner preferred modal since installs finish in Personas); carousel + list;
  brand icons for connectors; default generated hero art. Iframe-HTML idea discussed and dropped
  (trust model). Team publish + persona updates designed, phased later.
- **2026-07-01 (2)** — Owner: "should the Slack-channel feature live in the right-side Sources panel?"
  Decided **yes** (§14): a **child panel with back** off the connected Slack/Telegram row manages the
  session's subscribed channels — not the composer `+` menu. Pure GUI, existing subscription APIs.
- **2026-07-01** — Owner: convert the Settings **modal → page** like Integrations, and re-shell
  **Activity** too. Decided **Option 2** (§13): Settings = Appearance/Files/Models/Personas;
  Integrations keeps Connectors/Messaging/MCP/Activity. Models + Personas re-skinned; modal + dead
  tabs removed; shared bodies → `ManageTabs.tsx`.
- **2026-06-30** — Owner: sidebar "very busy" vs Claude/Codex. Decided: **bottom → Inbox + one ⚙
  "Settings & more" menu** (§12), folding Settings/Integrations/Automations/Activity into a
  click-to-open popup. 5 rows → 2.
- **2026-06-29 (3)** — Owner Q on the session top bar ("why the model name? what is 'Interactive'?").
  Decided: **remove both top-bar chips** (§11) — they duplicated the composer's model + "Ask for
  approval" controls, with the mode chip mislabeled vs. the composer. App + mock updated.
- **2026-06-29 (2)** — Confirmed: §4 hierarchy, §8 split button. New: grouped-nav needs clear
  **boundaries** + a per-persona **gear** (§7); **Persona detail page** (§9). Tabled: **persona
  enablement/onboarding** (§10) — keep it lightweight/progressive, not a gated wizard.
- **2026-06-29** — Req: `⚠ N` badge (not "N recommended"); per-session enable/disable toggle
  (hierarchy §4); richer manifests with `recommends` (§5, built); dual left-nav layouts (§7);
  New-session persona dropdown (§8). Owner endorsed the persona-connection drawer concept (§3).
- **2026-06-28/29** — Initial redesign asks: connector message card (§2a), Sources bar (§3),
  Integrations de-clutter (§6), connector-agnostic design (§1), collapsible steps (§2b).

---

### Openworker Archive Docs Email Connector Spec (`openworker-archive/docs/email-connector-spec.md`)

# Email (IMAP/SMTP) connector — spec

Status: draft for review · Target: `platform/coworker/connectors/`

## Why

Today the only email path is the `gmail` connector, which asks the user to paste a raw
Google OAuth access token. Those tokens expire in ~1 hour and minting one requires a
Google Cloud project — it is effectively demo-only. Until our managed OAuth client is
verified (Calendar first; Gmail blocked on CASA), the practical way for a user to give
the agent their mailbox is the same one desktop mail clients have used for decades:
**IMAP/SMTP with an app password**. Setup is ~2 minutes for Gmail (myaccount.google.com/apppasswords)
and the credential never leaves the user's machine, matching our privacy story.

One connector covers Gmail, iCloud, Fastmail, and any custom IMAP server. Microsoft
killed basic-auth IMAP for Outlook.com, so Outlook stays on its Graph connector.

## Decisions

1. **New connector `email`** ("Email (IMAP)"). The existing `gmail`/`outlook` API
   connectors stay untouched; `email` becomes the recommended path for Gmail in docs.
   Later, the same connector gains an OAuth mode (Gmail IMAP supports XOAUTH2, reusing
   the PR #302 token manager) — app password and OAuth become two auth modes of one
   implementation.
2. **Stdlib only**: `imaplib` + `smtplib` + `email`. No new dependency (validated by
   Hermes' production gateway, which is pure stdlib). Tool functions are sync like the
   rest of `integration_tools.py`; one fresh connection per call, no pooling, no IDLE.
3. **Provider presets by address domain**, not a select field: the user enters address +
   app password; we infer hosts for known domains (`gmail.com`, `googlemail.com`,
   `icloud.com`/`me.com`/`mac.com`, `fastmail.com`). Optional advanced fields override
   hosts/ports for custom servers. Keeps the existing `Field` model (all text inputs) —
   no setup-wizard UI changes needed.
4. **Send and attachment download are approval-gated; v1 has no destructive tools**
   (no delete, no move, no flag changes). Pure reads (list/search/read) are
   approval-free like the other connectors' read tools; downloading an attachment is
   gated because it writes an untrusted file to disk.

## Descriptor

```
/* Detailed source-code truncated for AI context efficiency. */
```

`_validate_email`: resolve preset → IMAP4_SSL login + `SELECT INBOX` (read path) and
SMTP STARTTLS login (send path), each with a 15s timeout; identity returned as
`"you@gmail.com · INBOX: 1,234 messages"`. Both must pass — a Gmail account with IMAP
disabled fails here with an actionable message, not at first tool call.

Secrets profile `email:default`:
`{address, app_password, display_name?, imap_host?, imap_port?, smtp_host?, smtp_port?}`
— stored exactly as entered; preset resolution happens at use time so we can improve
preset data without migrating stored profiles.

### Presets

| domain | IMAP | SMTP | sent folder |
|---|---|---|---|
| gmail.com, googlemail.com | imap.gmail.com:993 | smtp.gmail.com:587 (STARTTLS) | `[Gmail]/Sent Mail` (auto-saved by Gmail — never APPEND) |
| icloud.com, me.com, mac.com | imap.mail.me.com:993 | smtp.mail.me.com:587 | `Sent Messages` |
| fastmail.com | imap.fastmail.com:993 | smtp.fastmail.com:465 (TLS) | `Sent` |
| other | from advanced fields | from advanced fields | best-effort `Sent` |

## Tool surface

New module `platform/coworker/connectors/email_tools.py`, wired into
`make_integration_tools` and `tool_defs.py` like the existing connectors
(`__coworker_schema__` + `__aisuite_tool_metadata__`, creds read from SecretStore at
execution time, never in prompts).

| tool | kind | approval | params | returns |
|---|---|---|---|---|
| `email_list_folders` | read | no | — | folder names + message counts |
| `email_search` | read | no | `folder="INBOX"`, `from_address`, `to_address`, `subject`, `text`, `since`, `before` (YYYY-MM-DD), `unread_only`, `max_results` (default 10, cap 25) | newest-first envelopes: `{uid, date, from, to, subject, unread, has_attachments}` (no body snippet in v1 — it would need a per-message body fetch) |
| `email_read` | read | no | `uid`, `folder="INBOX"` | decoded headers, text body (text/plain preferred, HTML stripped as fallback, truncated at 20k chars), attachment list (filename, size, content type — not content) |
| `email_download_attachment` | read | **yes** | `uid`, `filename`, `folder` | saves into the session scratch dir only; returns the saved path (shows up in the artifact viewer). Approval-gated: attachments are untrusted files landing on disk — the card shows filename, size, and sender |
| `email_send` | write | **yes** | `to`, `subject`, `body`, `cc=""`, `bcc=""`, `reply_to_uid?` + `reply_to_folder?`, `attachments?` (paths within granted roots/scratch) | `{ok, message_id}` |

Search builds IMAP `UID SEARCH` criteria (`FROM`, `TO`, `SUBJECT`, `TEXT`, `SINCE`,
`BEFORE`, `UNSEEN`) with `CHARSET UTF-8`; fetches envelopes via
`BODY.PEEK[HEADER.FIELDS (...)]` — **PEEK everywhere so we never flip the user's
unread flags**. `email_read` fetches `BODY.PEEK[]` and parses with stdlib `email`
(RFC 2047 header decoding; walk parts, prefer `text/plain`, strip HTML fallback,
skip attachments).

`email_send` builds an `EmailMessage` (`From` = display name + address, `Date`,
generated `Message-ID`); when `reply_to_uid` is given, fetches the original's
`Message-ID`/`Subject` first and sets `In-Reply-To`, `References`, and `Re:` subject
so replies thread correctly. Sends via SMTP STARTTLS (or implicit TLS on port 465).
The approval card shows recipient, subject, and full body. v1 does not APPEND a copy
to the Sent folder (Gmail does it server-side; other providers documented as a
follow-up) — deliberate, to keep send single-shot and avoid Hermes' documented
footgun where a failed post-send step makes callers retry SMTP and double-send.

Failure messages are actionable: auth failure on a Gmail preset says "check that
2-Step Verification is on and this is an app password, not your account password."

## Testing

- Constructors are injectable (`imap_factory=imaplib.IMAP4_SSL`, `smtp_factory=...`)
  mirroring the providers' `client=` pattern; unit tests use recording fakes — no
  network, no SDK.
- Unit coverage: preset resolution + advanced-field override; search-criteria builder
  (dates, quoting, unread); envelope/header decoding (RFC 2047, malformed charsets);
  body extraction (multipart, HTML fallback, truncation); reply threading headers;
  attachment path confinement to scratch dir; send approval metadata.
- Live smoke script (`/tmp/test_email_connector.py`) against a real Gmail app
  password: list folders, search newest 5, read one, send-to-self round-trip.

## Privacy notes (for docs/quickstart)

- The app password is stored in the local SecretStore and used only to connect to the
  user's own mail server; it is never sent to any LLM provider or to us.
- Email *content* the agent reads does go to the configured model provider as context —
  same as any file the agent reads. Say this plainly in the quickstart.
- An app password grants full mail access; recommend revoking it from the provider's
  security page if the user stops using OpenCoworker.

## Out of scope (v1)

- Delete / move / flag tools (destructive; revisit with explicit per-tool opt-in).
- IMAP IDLE / inbound email-as-channel (future MyHelper inbound gateway can reuse the
  same profile + preset data).
- XOAUTH2 auth mode (follow-up; reuses PR #302's token manager once merged).
- Microsoft personal accounts (basic auth removed by Microsoft; Graph connector covers it).
- APPEND-to-Sent for non-Gmail providers.

---

--- METRICS ---
- Files Extracted: 47
- Estimated Token Budget: ~86725 tokens
- Canonical Reference: https://codewiki.google/github.com/andrewyng/aisuite
