Technical Documentation: andrewyng/aisuite
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
OpenWorker
A desktop AI coworker, built on aisuite โ now in its own repository: 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 macOS 13+ (Apple Silicon) ยท โฌ Download for Windows Windows 10/11 (x64) ยท Quickstart
OpenWorker development has moved to the new repo. A historical snapshot of its source remains in
openworker-archive/.
aisuite
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:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 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 โ 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 โ 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 โ 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:
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 SDKsYou'll also need API keys for the providers you call โ the Chat Completions quickstart covers key setup and your first calls.
Looking for the OpenWorker desktop app? Downloads are on its releases page.
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 :<model-name>; aisuite routes the call to the right provider with the right parameters:
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 โ 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:
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).
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`
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:
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, so any MCP server's tools can be handed to a model without boilerplate (pip install 'aisuite[mcp]'):
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 โ 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.py |
| Class name | Provider (capitalized) |
Example:
# 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 and join our Discord 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:modelformat (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
npm install aisuiteQuick Start
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
// 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
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
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
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
// 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
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
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:
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:
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
// 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'); // trueCurrent 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
# 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:examplesLicense
MIT
Overview (`guides/README.md`)
Provider guides
These guides give directions for obtaining API keys from different providers.
Here are the instructions for:
- Anthropic
- AWS
- Azure
- Cohere
- Gemini
- Groq
- Hugging Face
- Mistral
- OpenAI
- Featherless
- SambaNova
- xAI
- DeepSeek
- Eden AI
For locally hosted models using Ollama or LM Studio, follow these instructions:
Unless otherwise stated, these guides have not been endorsed by the providers.
We also welcome additional contributions.
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.
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 asrelease-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.
pip install streamlitYou 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:
streamlit run chat.pyYou 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:
Node.js and npx - Required to run the Anthropic filesystem MCP server
- Install from: https://nodejs.org/
- Verify with:
npx --version
Python test dependencies:
bashpip install pytest pytest-asyncio python-dotenvMCP package (should already be installed if you have aisuite[mcp]):
bashpip install 'aisuite[mcp]'Environment variables (for e2e tests that mock LLM calls):
Create a.envfile in the project root with your API keys:bashOPENAI_API_KEY=your-key-here ANTHROPIC_API_KEY=your-key-here EXA_API_KEY=your-key-here # Optional: for Exa MCP testsNote: 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):
pytest tests/mcp/ -v -m "integration and not llm"Run specific test file:
# 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 llmRun ONLY real LLM tests (โ ๏ธ costs ~$0.50):
pytest tests/mcp/ -v -m llmRun ALL tests including LLM (โ ๏ธ costs money):
pytest tests/mcp/ -v -m integrationRun a specific test:
pytest tests/mcp/test_client.py::TestMCPClientConnection::test_connect_to_filesystem_server -vSkip integration tests (if no Node.js):
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)
- Tools:
- 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
- Tools:
- Context7 HTTP MCP server (
- 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 filesskip_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:
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)
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:
- โ Connection to real MCP servers (stdio and HTTP)
- โ Tool discovery and schema parsing
- โ Tool execution and result handling
- โ Config dict โ callable conversion
- โ Tool filtering and prefixing
- โ Integration with aisuite's tool system
- โ Proper resource cleanup
- โ Error handling
- โ HTTP transport with headers and timeout
CI/CD
GitHub Actions
If running in CI without Node.js:
- name: Run tests
run: pytest tests/mcp/ -v -m "not integration"With Node.js:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Run integration tests
run: pytest tests/mcp/ -v -m integrationNotes
- Tests are marked with
@pytest.mark.integrationto allow selective running - Most tests use mocking for LLM API calls to avoid costs
- Real LLM tests are marked with
@pytest.mark.llmand 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:
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
- Clone the repository and navigate to the chat app directory:
cd aisuite-js/chat-app- Install dependencies:
npm install- Start the development server:
npm run dev- Open your browser and navigate to
http://localhost:3000
Configuration
API Keys
- Click the "Configure API Keys" button in the header
- Enter your API keys for the providers you want to use
- 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
- Configure your API keys
- Select a model from the dropdown
- Type your message and press Enter or click Send
- View the AI response
Comparison Mode
- Enable "Comparison Mode" checkbox
- Select two different models
- Send a message to see responses from both models side-by-side
- 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 stylesAvailable Scripts
npm run dev- Start development servernpm run build- Build for productionnpm run preview- Preview production buildnpm run lint- Run ESLint
Adding New Models
To add new models, edit src/config/llm-config.ts:
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
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests if applicable
- Submit a pull request
License
MIT License - see the main repository for details.
Support
For issues and questions:
- Check the AISuite documentation
- 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:
cd cli/py/aisuite-code-cli
python3 -m poetry install
python3 -m poetry run aisuite-code --cwd /path/to/projectFrom the repository root, use the convenience shim:
./scripts/aisuite-code --cwd /path/to/projectThe 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:
/help
/viewer
/viewer start
/status
/clear
/exitBy 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 asreview_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.
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 platform/packaging/setup_dev_env.sh # โ platform/.venv (server + this repo's aisuite)Run it (browser, two terminals)
- Start the server (needs a model key, e.g.
OPENAI_API_KEY, in the environment โ
or add one later in the app's Settings):bashcd platform ./.venv/bin/coworker-server --cwd /path/to/your/project --port 8765 - 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 withVITE_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/).
cd platform/surfaces/gui
npm install # first time
npm run tauri dev # builds the shell, launches the window, starts the serverTests
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
npm run e2e # headless
npm run e2e:ui # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts # a single specLive 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/healthand/v1/providersreturn 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 producefib.mdand
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:readyon connect;user_messageโturn_startโ deltas โassistant_message "Echo: <text>"โturn_done; a message containing "run a tool" emitstool_proposed+permission_requiredand suspends until the client'sapprovaldecision
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-notesis 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/providersflipsconfiguredon save,/verifyfails on a key containing "bad". One
automation ("Daily AI News") with a running run โPOST .../runappends a run,PATCH/DELETE
toggle and remove.
Adding a spec
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). PrefergetByRole, 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.
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.
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:
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 aToolPolicyContext. - State stores โ persist runs and resume them across processes:
InMemoryStateStore,FileStateStore, orPostgresStateStorewiththread_id. - Artifacts โ store what the agent produced (
FileArtifactStore,InMemoryArtifactStore). - Tracing โ every
RunResultcarries its steps, raw responses, and atrace_id; plug in trace sinks for observability.
MCP tools
Any Model Context Protocol server's tools can be handed to a model (pip install 'aisuite[mcp]').
Inline config for simple cases:
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:
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 for detailed usage.
Going further
- Want a ready-made desktop AI coworker instead of building your own? See the OpenWorker quickstart.
- A historical OpenWorker snapshot is preserved in
openworker-archive/as a reference for building a full agent harness with aisuite. Active OpenWorker development lives in the OpenWorker repository.
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
pip install aisuite # base package, no provider SDKs
pip install 'aisuite[anthropic]' # with one provider's SDK
pip install 'aisuite[all]' # with every provider SDK2. Set your API keys
You need keys only for the providers you call. The provider guides walk through obtaining a key for each one.
Set them as environment variables (tools like python-dotenv or direnv help manage them):
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:
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:
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 โ no API key required:
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/(<provider>_provider.py). - Runnable notebooks live in
examples/. - Ready to give the model tools? Continue with the Agents quickstart.
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 |
| Windows 10/11 (x64) | 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. A historical source snapshot is preserved under openworker-archive/ as a reference for building an agent harness with aisuite (see the Agents quickstart).
Guides Anthropic (`guides/anthropic.md`)
Anthropic
To use Anthropic with aisuite you will need to create an account. Once logged in, go to the API Keys
and click the "Create Key" button and export that key into your environment.
export ANTHROPIC_API_KEY="your-anthropic-api-key"Create a Chat Completion
Install the anthropic python client
Example with pip
pip install anthropicExample with poetry
poetry add anthropicIn your code:
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.
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 tous-west-2. Anywhere the region is specified can be replaced with your desired region.
Navigate to the 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
page to enable the models you would like to use.
After enabling the foundation models, navigate to providers page
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:
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
pip install boto3Example with poetry
poetry add boto3In your code:
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.
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
- Visit Azure Portal and sign up for an account if you don't have one.
- Create a project and resource group.
- Choose a model from https://ai.azure.com/explore/models and deploy it. You can choose serverless deployment option.
- 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.
- 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".
- 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:
- API Key: Found in the "Keys and Endpoint" section of your Azure OpenAI resource.
- 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/ - API Version: Optional configuration and mainly introduced for Azure OpenAI services. Once specified, the
api-versionquery parameters will be added in the end of the API request.
Set the following environment variables:
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:
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.
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 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_warmingupon construction to reduce the TTFT. If this behaviour is not desired, setwarm_tcp_connection=Falsein 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.
Usage
Get an API Key from cloud.cerebras.ai and add it to your environment variables:
export CEREBRAS_API_KEY="your-cerebras-api-key"Use the python client.
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. After logging in, go to the 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:
export CO_API_KEY="your-cohere-api-key"Create a Chat Completion
Install the cohere Python client:
Example with pip:
pip install cohereExample with poetry:
poetry add cohereIn your code:
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.
Guides Crusoe (`guides/crusoe.md`)
Crusoe
To use Crusoe Managed Inference with aisuite, you need a Crusoe Cloud account. Log in to the Crusoe Cloud Console and generate an API key. Once you have a key, add it to your environment as follows:
export CRUSOE_API_KEY="your-crusoe-api-key"Create a Chat Completion
Install the openai Python client:
Example with pip:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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 for the full list of available models.
Happy coding! If youโd like to contribute, please read our Contributing Guide.
Guides Deepseek (`guides/deepseek.md`)
DeepSeek
To use DeepSeek with aisuite, youโll need an DeepSeek account. After logging in, go to the API Keys section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:
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:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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.
Guides Edenai (`guides/edenai.md`)
Eden AI
To use Eden AI with aisuite, you'll need an Eden AI account. After logging in, open your account settings and generate an API key. Once you have your key, add it to your environment as follows:
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:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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.
Guides Featherless (`guides/featherless.md`)
Featherless.ai
To use Featherless with aisuite, you'll need an featherless account. After signing up, go to the API Keys. Once you have your key, add it to your environment as follows:
export FEATHERLESS_API_KEY="your-featherless-api-key"Create a Chat Completion
Install the openai Python library:
Example with pip:
pip install openaiIn your code:
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.
Guides Gemini (`guides/gemini.md`)
Gemini (Gemini Developer API)
This provider talks to Google's Gemini Developer API 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 provider instead.)
Get an API key
Visit Google AI Studio, sign in with a Google account, and click "Create API key."
Set the API key in an environment variable
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
pip install 'aisuite[gemini]'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 on getting started with their platform, but here are the basic steps:
Create your account.
Visit Google Cloud 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 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 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, and the basic steps are as follows:
- Visit the service accounts page in the Google Cloud Console.
- Click the "+ Create Service Account" button toward the top of the page.
- Follow the steps for naming your service account and granting access to the project.
- Click "Done" to create the service account.
- Now, click the "Keys" tab towards the top of the page.
- Click the "Add Key" menu, then select "Create New Key."
- Choose "JSON" as the key type, and click "Create."
- Move this file to a location on your file system like your home directory.
- Set the
GOOGLE_APPLICATION_CREDENTIALSenvironment 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_IDGOOGLE_REGIONGOOGLE_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:
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:
pip install vertexaiIn your code:
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.
Guides Groq (`guides/groq.md`)
Groq
To use Groq with aisuite, youโll need a free Groq account. After logging in, go to the API 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:
export GROQ_API_KEY="your-groq-api-key"Create a Python Chat Completion
- First, install the
groqPython client library:
pip install groq- Now you can simply create your first chat completion with the following example code or customize by swapoping out the
model_idwith any of the other available models powered by Groq andmessagesarray with whatever you'd like:
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.
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
- Visit Hugging Face and sign up for an account if you don't already have one.
- Explore conversational models on the Hugging Face Model Hub and select a model you want to use. Popular models include conversational AI models like
gpt2,gpt3, andmistral. - 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.
- 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.
Set the following environment variables to make authentication and requests easy:
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:
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
modelvariable 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.
Guides Lmstudio (`guides/lmstudio.md`)
LM Studio
LM Studio allows users to locally host open-source models available in their model catalog.
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:
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.
Guides Mistral (`guides/mistral.md`)
Mistral
To use Mistral with aisuite, youโll need a Mistral account.
After logging in, go to Workspace billing and choose a plan
- Experiment (Free, 1 request per second); or
- Scale (Pay per use).
Visit the API Keys section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:
export MISTRAL="your-mistralai-api-key"Create a Chat Completion
Install the mistralai Python client:
Example with pip:
pip install mistralaiExample with poetry:
poetry add mistralaiIn your code:
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.
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 and press "Log in to AI Studio" in the right top corner. After logging in, go to the API Keys section and generate a new key. Once you have a key, add it to your environment as follows:
export NEBIUS_API_KEY="your-nebius-api-key"Create a Chat Completion
Install the openai Python client:
Example with pip:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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.
Guides Ollama (`guides/ollama.md`)
Ollama
Ollama allows users to locally host open-source models available in their 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:
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.
Guides Openai (`guides/openai.md`)
OpenAI
To use OpenAI with aisuite, youโll need an OpenAI account. After logging in, go to the API Keys section in your account settings and generate a new key. Once you have your key, add it to your environment as follows:
export OPENAI_API_KEY="your-openai-api-key"Create a Chat Completion
Install the openai Python client:
Example with pip:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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.
Guides Sambanova (`guides/sambanova.md`)
Sambanova
To use Sambanova with aisuite, youโll need a Sambanova Cloud account. After logging in, go to the API section and generate a new key. Once you have your key, add it to your environment as follows:
export SAMBANOVA_API_KEY="your-sambanova-api-key"Create a Chat Completion
Install the openai Python client:
Example with pip:
pip install openaiExample with poetry:
poetry add openaiIn your code:
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.
Guides Tongyi (`guides/tongyi.md`)
Tongyi
To use the Alibaba Tongyi model, you need to log in to Alibaba Cloud Bailian to obtain an API key. Once you have the API key, set it as an environment variable as follows:
export TONGYI_API_KEY="your-api-key"Create a Chat Completion
Example Using pip:
pip install aisuite[all]Example with poetry:
poetry add aisuiteIn Your Code:
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.
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
- Visit IBM Watsonx.
- Sign up for a new account or log in with your existing IBM credentials.
- Once logged in, navigate to the Watsonx Dashboard (https://dataplatform.cloud.ibm.com)
Step 2: Obtain API Credentials
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.
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.
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:
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:
pip install ibm-watsonx-aiExample with poetry:
poetry add ibm-watsonx-aiIn your code:
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. Generate a new key and once you have your key, add it to your environment as follows:
export XAI_API_KEY="your-xai-api-key"Create a Chat Completion
Sample code:
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.
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
- 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. - 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. - 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>:defaultis a
POINTER ONLY:{"default_account": <id>}(plus connector-wide settings likeenabled). 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) | |
| hunter | validator identity (account email) |
Descriptor gains account_field: str = "" โ the creds field (or "@identity"
sentinel = use ValidationResult.identity) that names the account. Non-emptyaccount_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>viaadd_accountinstead 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 carriesworkspace_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
fromGET /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, tokenhttps://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(defaulthttps://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
Providerentries:notion(Basic-auth token exchange, no refresh,
forwardworkspace_id/workspace_name/bot_idin the callback result) andattio(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 theocw-connect/oauth-providerssecret
(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 accountswhen >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 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, defaulthttps://slack.com/api/) and pass it to theAsyncWebClient(base_url=โฆ)the app uses. Slack'sapps.connections.openis 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_boltdecodes (verify against the installedslack_bolt/slack_sdkversion 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), withserver.py(aiohttp app โaiohttpis already a dep) and__init__.pyexportingFakeSlack. - Embedded (pytest): a fixture
fake_slackthat starts the server on an ephemeral port, setsSLACK_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 8910runs it;
print theSLACK_API_URLto 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)
SlackAdapterwithSLACK_API_URLโ fake connects:connect()returns True,auth.testresolved
the bot id, Socket Modehelloreceived.POST /control/inboundโ the gateway's inbound handler fires with aMessageEventwhoseuser_name/chat_nameare resolved from the registered tables (exercisesusers.info+conversations.infocaching).- Agent/test calls
send/send_interactiveโ recorded underGET /control/outboundwith the right
channel/text/blocks. POST /control/interactionโ the adapter'socw_*action handler fires โ reachesGateway._on_interaction(resolves the inbox item).POST /control/resetreturns the fake to a clean slate between tests.- A focused test proves the real
slack_bolthandler 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.readonlywrite: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 asaccountfrom the id_token).
Non-goals (wave 1)
- Docs WRITE (create/edit Google Docs) โ briefs land as local artifacts or
Notion pages; revisit on demand (documentsscope 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)
googleproviderconnector_scopesgains"google_docs": ["documents.readonly", "spreadsheets.readonly"]
(full URLs). The existinggoogle_drivestub stays for wave 2.- Model extension: access tiers must be PER CONNECTOR on multi-connector
providers โ todayaccess_scopesis provider-level (fine for HubSpot, a
single-connector provider; wrong for google, where awritetier must not
leak into gmail/calendar consents). Addconnector_access_scopes: dict[connector, dict[tier, list[scope]]];startresolves connector tier first, then provider-level (back-compat).
Forgoogle_docs:writeREPLACESspreadsheets.readonlywithspreadsheets(not additive โ avoid redundant scope pairs). - Callback: nothing new โ google identity extraction (email from id_token)
already covers it;account_idalready 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_tokenpaste (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 passesaccesstier throughbegin_managed_connect(HubSpot precedent); the
granted scope string is stored on the profile โ connector_list exposesaccess: "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 APIdocuments.get, body flattened
to readable text: headings prefixed (#/##), list items bulleted, table rows
ascell | cell | celllines. 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:D50or justSheet1):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 includesspreadsheets; 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: anaccessbadge per account row (read-onlytag when the
grant lacksspreadsheets) โ same affordance as HubSpot's portal rows. - Add-modal: One click | Manual pills with the read/write radio on the
one-click pane โ generalizeHubSpotOneClickinto the access-tier variant
ofGenericOneClickinstead 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
- Broker: per-connector access tiers + google_docs scopes (+tests).
- Desktop: descriptor/provider map/URL parser/read tools (+tests).
- Desktop: write tools + tier gating (+tests).
- GUI: access badge + one-click radio generalization (+e2e), visual review.
- 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_createlands 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):
- Check Current status below, then the first phase that isn't โ .
- Do the next unchecked milestone; make its completion gate (the named tests) pass.
- 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: fullnpm 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 (newGET /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 inPERSONAS.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_usertool; durable correlation. Spec:MESSAGING-AND-SESSIONS.md.
๐งญ RESUME HERE (next session, after compaction) โ remaining-work plan
Worktree
/Users/rohit/fleet/ro4d/aisuite-personas, branchplatform/personas(local-only,
Devika-authored, 0 behind origin/main). Test runner:./.runtests.sh. Servers may be running:
backend127.0.0.1:8765, vitelocalhost: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 theanthropic/google-genaiSDKs, so a server started under it
errors every turn withModuleNotFoundError: No module named 'anthropic'(the default model isanthropic: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:
- โ Phase 4 (frontend) โ attention/liveness badges, family-aware App gating, Searchโtop,
Inboxโsession link (answer-in-context B). Committedd0b64d6. 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).- Phase 5 messaging (per
MESSAGING-AND-SESSIONS.md):
- โ
ask_usertool (commits181260b,238b0c4) โ engine-intercepted likerequest_directory. Mode-aware routing (the key contract): Unattended โ KIND_QUESTION
Inbox item + suspend oninbox.wait(manager.inbox_question_asker, also the default for
background/self-wake runs); attended โ a livequestion_requestedevent answered inline
from the composer (question_response), NO Inbox item โ the Inbox is for Unattended only.
Question + optional quick-replyoptions+ free-text escape +multi(mirrors Claude Code's
AskUserQuestion);InboxItemCardrenders 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 viainbox.wait(the
per-connection asyncio queues are gone), so they survive a dropped socket (redelivered on
reconnect) and resolve from any surface.visibilitydecides where a prompt shows, not how
it works: attended โ inline only (the cross-session Inbox list filtersinlineout, so the
Inbox = explicit Unattended only); unattended โ cross-session Inbox + answer-in-context.
Backend:InboxItemgainsvisibility+data+directory/plankinds; live WS responses
resolve the session's single pending item, REST resolves by id; approver takes both
vocabularies. Frontend:InboxItemCardrenders directory/plan; live cards suppressed when
Unattended (App tracks it via ref + toggleonChange). 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 toolssubscribe_channel
/unsubscribe_channel/list_subscriptions/get_channel_messages(ring buffer); agent
bootstraps viaask_user, parses Slack<#id|name>. Gateway dispatch: token โ Inbox; channel
msg โ buffer + fan out to subscribers viamanager.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 inMESSAGING-AND-SESSIONS.md. Tested
(store/parsing/buffer/tools/dispatch fan-out). Not yet: GUI to view/manage subscriptions;@botmention surfacing inMessageEvent; 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-agnosticButton; 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).- โ Hands-on testing (done 2026-06-27, via WS+REST against the running server, real model):
(A) self-wake โsleep_forโ schedulerresume_due_wakesresumed the session in ~16s: PASS.
(B) Unattended โ awrite_fileapproval parked in the Inbox (realattention=1, livenessworking), resolving it released the suspended agent and the file was written: PASS. Not yet
tested: the free-textask_user/KIND_QUESTION path (doesn't exist until Phase 5 โ only the
KIND_APPROVAL path is wired). A review sessionhandson-unatt-6a3fed71is 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).- 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 โ typeslack:Ctestto exercise the UI.)- 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:
- โ
Approver-swap โ the
/ws/sessionapprover routes to the Inbox + suspends when the
session is Unattended (reuses the tested inbox machinery; resolved via/v1/inbox/.../resolve). - โ
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) callsresume_due_wakes(), which delivers the wake message busy โqueue_steeringinto 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. - โ
Gateway inbound โ
Gateway.reply_resolverconsumes 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. - โ
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.tsextended. Fullnpm run buildpasses. Deferred polish for review: composer-disable while unattended;
inbox routing config UI; reactive persona refresh after enable; inbox unread badge.
- โ
Approver-swap โ the
- Base: branch
platform/personasofforigin/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 withPYTHONPATH=<root>/platform:<root>). That venv lacks the anthropic/google-genai SDKs andblack, 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.pyreads it instead ofWRITE_TOOLS/SHELL_TOOL/requires_approval
(those re-exported for back-compat). - โ
catalog.pyโ capabilities (code_files,files,git,search,shell,todo) withrequires+risk;expand(ids, ctx)skips capabilities whose context is absent. - โ
agents/code.py+agents/cowork.pybuild viaexpand(...)(hand-written factories gone). - โฌ
never_unattended_autofloor 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โclassifymapping (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 theagent.name == โฆbranching inbuild_engine+manager.get_engine(so Ops behaves like Cowork: orphan scratch, connectors,
scheduling, request_directory). Session binding/pin/rename ride existingSessionRecordfields. - โ
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_agenttraits + 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+ registryinstall_from_dir/install_from_git(injectable clone);consent_summary; installed sources persist + reload; third-party personas land
disabled+unsurfaced pending consent. RESTPOST /v1/personas/install. - โ
overrides.pyโ user-localRiskOverrideStore(glob, most-specific wins), wired intobuild_engine's PermissionEngine. Never written by persona loading (no-self-grant). - โ
unattended.pyโ per-session toggle registry (persisted). RESTPOST /v1/sessions/{id}/unattended. (Composer-disable + one-tap confirm = GUI, deferred.) - โ
inbox.pyโInboxStore(3 kinds),pendingโresolvedstate machine (idempotent,
first-responder-wins),reconcile_on_resume, asyncinbox_approver. RESTGET /v1/inbox,POST /v1/inbox/{id}/resolve,GET /v1/inbox/reconcile. - โ
selfwake.pyโWakeStore(timer + on-completion) +due()/complete_job()+ thesleep_for/sleep_until/wake_ontools. - โฌ 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_dirnow
copies manifests into<data>/personas-installed/<id>/(stable, source-independent);install_from_gitclones 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). RESTGET /v1/inbox/routing,POST /v1/inbox/routing/binding. - โ
On-event wake:
WakeStore.add_event/fire_event+ thewake_on_eventtool. - โฌ 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 + thewake_on_eventtool.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'sneedsWorkspace/gatesWorkspacenow read the persona'sneeds_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_sessionsnow returnsattention(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_sessionsreturnsliveness(working= in-flight turn viais_running,sleeping= a self-wake pending viawakes.pending, elseidle). 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-navabove 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 withInboxView), 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 stockapi.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 custombase_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 withisProjectScoped): the button only set the gate flag,
but the gate renders behindsurface==="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+buildclean, vitest
20/20. Commits60f2d44(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 theposition:absoluteglass 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 Riveravia the real users.info/conversations.info). The "legacy-looking" messages were
pre-Phase-2 data (nosource); 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+buildclean, vitest 20/20. Commits776737a,abeaba0(+ the surface ports). Note: a demo connector message + a denied turn were left in
the localhisession by the live card test โ delete if unwanted. The_say is not a valid argumentSlack
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 andui-mocks/redesign.htmlshare 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 incolor-mixso the mock's/NNopacity 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+buildclean, 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 newnav_layoutpref, grouped cards carry a
gear โ PersonaView); a New-session split button (primary = last persona, โพ = enabled personas + "Manage
personasโฆ"); and aStepGroupthat collapses tool/approval items into "N actions ยท M approvals โ".ConnectorBadge(real brand color/logo) now also renders on the connector rows. Backend:nav_layoutpref
(GET /v1/settings+POST /v1/settings/nav-layout). Product bug found + fixed by the e2e: the asyncSlackAdapter.send/send_interactivewrapped 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
viaasyncio.to_thread(matches the engine's send path). The merge gatetest_ui_refresh_e2e.pydrives
the WHOLE refresh against FakeSlack with the real SlackAdapter/slack_bolt stack: connect โ channel message
becomes a structured connector card with names resolved via realusers.info/conversations.info(provider
gets framed text, nosource) โ 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 transcriptConnectorMessageCard; sessiondetail
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 +recommendswithconnectedannotated +default_connections);POST /v1/personas/{id}/ connections(set persona default);POST /v1/personas/{id}/enable(delegates to the registry);GET /v1/sessions/{id}/connections(effectiveconnectedw/ a detail string +recommendednot-yet-connectedattentioncount);POST /v1/sessions/{id}/connections(session override,clearto inherit). Frontend:PersonaView(identity + Enable toggle + capabilities + recommends + "new sessions get by default" toggles),SourcesBar(avatar stack + โ N) +SourcesDrawer(per-session connection toggles), a sharedToggle, andconnectors/visuals.tsthreading real brand_color/logo from/v1/connectorsinto these surfaces (closes
the Phase-2 neutral-gray follow-up for the connection surfaces; the transcriptConnectorMessageCardis 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): personadefault_connectionslists the
recommended defaults (spec-literal); the sessionconnectedlist shows ALL effective connectors (honest
where "why is this on" matters). Tests:test_persona_connections.py(5, isolated viaCOWORKER_STATE_DIR)
โ Python 498 passed (3 pre-existing SDK errors); vitest 17 passed;tsc+buildclean.
Test-hygiene fix (separate commit): isolatedtest_connections.py(Phase 3) โ it built a realSecretStoreagainst the global state dir and wrote a fakegithub:defaultinto the developer's real~/.config/coworker/secrets.json. Now pinned to a tmpCOWORKER_STATE_DIR(verified: global secrets hash
unchanged across a run). Known/flagged (pre-existing, needs Rohit): other tests (test_attachments,test_mcp, historicallytest_connectors) still write to the real state dir; a fakegithub:default
(ghp_test) currently sits in the real secrets store (deletion was correctly blocked โ pending Rohit's call).
Deferred: sessiondetailshows chat ids (names need the live adapter cache);recommendedexcludes mcp.
Next: Phase 5 (frontend polish + e2e gate).
2026-06-29 ยท Devika /
platform/personasยท UI-Refresh Phase 3 โ connection hierarchy (load-bearing data model).
Newconnections.py:PersonaConnectionStore(per-persona default on/off, seeded from manifestrecommends)SessionConnectionStore(per-session overrides) + a pureeffective()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 (aconnector_filter
threaded intobuild_engine, applied in BOTHget_engineand_build_task_engine) and inbound delivery
(_dispatch_inboundchannel + DM paths skip a muted connector but still buffer).delete_sessiondrops the
session's overrides. Adversarially reviewed before commit โ the resolver was proven correct; the review
caught + we fixed: (1) MAJOR seed-staleness โ atier:coreconnector 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 ontoeffective_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-detaildefault_connectionswill 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-onlysourcesidecar so the GUI renders a rich card while
the model still gets the framed text. Backend:MessageSourcedataclass (ยง3.1);engine.run(..., source=)
stores it on the user message and emits it inTURN_START;_outbound_messagesstripssource
unconditionally (the sole provider feed โ proven by a no-context strip test);deliver_to_session+_dispatch_inboundbuild+thread the source on both channel and DM paths (steering path too); persisted
verbatim (store json-dumps each message) soGET /messages+ WSturn_startsurface it for free. Frontend:ConnectorMessageCard(brand-tinted header/edge, names with id-on-hover swap, relative time); extracteditemsFromMessages.ts(testable) mapssource.connectorโ aconnectoritem;Transcriptrenders it.
Tests:test_message_source.py(5, incl. persisted-and-stripped) โ Python 485 passed (3 pre-existing
SDK errors); vitest 11 passed;tsc+buildclean. Updated 3 existing tests for the newdeliver_to_session/_outbound_messagescontract (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, notbrand_color; abrandColorprop 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:ConnectorDescriptorgainsbrand_color+logo;connector_listsurfaces them; placeholderavailable:falsedescriptors 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) + publicresolve_user_name/resolve_channel_name
wrappers, and populates a newSessionSource.chat_namein_on_message(mirrorsuser_name). Frontend:src/connectors/registry.tsx(logo-id โ inline SVG +FALLBACK) andConnectorIcon/ConnectorBadge
(brand color from API, fallback plug on unknown id);Connectorinterface gainsbrand_color/logo. Stood
up a minimal vitest + @testing-library/react harness (separatevitest.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 buildclean. 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).
Builtcoworker/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-shapedevents_api+block_actionsenvelopes,
a/control/*HTTP API + programmaticFakeSlackobject, and a standalonepython -m coworker.testing.fake_slackrunner. One production change: aSLACK_API_URLbase-URL override on the
Slack adapter (boltAsyncWebClient) and both httpx senders (default = real Slack).aiohttp>=3.9
declared in themessagingextra (so CI installs it) + pip-installed into the test venv. Added thefake_slackpytest fixture (tests/conftest.py, monkeypatched env). Tests:test_fake_slack.py6/6
incl. a guard that the realAsyncSocketModeHandlerdispatches 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-gatedCOWORKER_DEBUG_INJECTendpoint (feeds the real
inbound path without a live bot) rode along inb4a822d; 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_startsurfaces the
inbound message as a user item. Fix 2 error visibility:unrouted.pydead-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 carriesallowed_users+ recent senders; ported the allow/recent UI from the orphaned super-agent
view. Fix 4+5 DM routing (prefsdm_session+/v1/messaging/dm-route; DM โ designated session
or parked) and retired the super-agent (deletedconnectors/superagent.py, the_sa_*surface,/ws/superagent,SUPERAGENT_SESSION_ID,SuperAgentView;myhelperkept 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).
WroteMESSAGING-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); GUInpm run buildgreen. 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). UpdatedPERSONAS.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 (addedGET /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+ fullnpm 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). Addedinbox_routing.py,WakeStoreevents, 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). Addedoverrides.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. Addedpersonas/
(manifest + registry), Ops markdown persona, Agent traits replacing name-branching in
build_engine/manager,/v1/personasendpoints. 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. Addedrisk.py+catalog.py; refactoredpermissions.pyto 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 existingreply_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
โ seePERMISSIONS-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 withask_userโ it doesn't autonomously know channels. (Slack encodes a
typed#alertsas<#C0123|name>, sosubscribe_channelparses 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 inboundMessageEvent 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:
- Bot-level mention = the activation filter (native, one identity). A message that
@OpenCoworkers 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. - 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. - 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_useroptions (one button
each).interactions.buttons_for(item)builds them;encode/decodeown 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);deliverembeds[ocw:<id>].
Subscription is the inbound counterpart of the outbound binding.inbox.pyโInboxStore+ state machine +wait/resolve. Needs:tool_call_idon items;ask_userpath; (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_messagescatch-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_usertool 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 (persisttool_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 usesession_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 viaask_userto learn the channel from the user. Slack#channelmention
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 viasend_messageto 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
@botin the inboundMessageEvent(Slack adapter hasbot_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 toPERSONAS.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:
- Tool catalog โ stable
id โ capability, what a persona references. - Risk class โ intrinsic side-effect category of each tool (
read/write_local/exec/external). Replaces today's hardcoded name sets. - Effective-risk override โ user-local only reclassification, mainly to relax MCP's
conservative default. Never carried by a persona. - Permission mode โ how autonomous: Plan / Interactive / Custom / Auto (today's
permissions.py). The ceiling. - 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:
[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 conservativeBoth 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 apropose_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โModeenum +evaluate()stay; replaceWRITE_TOOLS/SHELL_TOOL/requires_approvalwith declared risk classes read from the catalog; add theattended?
axis (route vs. ask); generalize Custom'sauto_allow_toolsto risk classes.tools/registry.pyโ stays runtime plumbing; the newcatalog.pysits 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) perPERSONAS.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/externalreplace hardcoded name
sets;externalis 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 =
codefamily (lives in IaC/repos) + shell + cloud/k8s connectors + ops prompt. - SecOps =
codefamily (scans repos) + security tools + alerting connectors + a read-leaning prompt. - Ops / Marketing / Sales =
knowledgefamily + 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_rootswith
per-rootwritable+ therequest_directorygrant 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.
---
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_modelsare 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 existingmodel_readymachinery), never a hard block.default_permission_modeis the starting posture. A persona cannot self-escalate; a
higher posture requires explicit user opt-in at enable time.
Lifecycle states
- Installed โ present on disk (built-in, local dir, or fetched from a git URL).
- Enabled โ the user turned it on in the Personas settings panel.
- 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 โ ManageRules:
- 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 inPERMISSIONS-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:
- 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?" - 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):
- Timer โ
sleep_for(duration)/sleep_until(time). - On-completion โ
wake_on(job_id)when a backgrounded command/job exits; the exit code
and output are handed back on resume. - On-message (free) โ a user steering message into the session wakes it (reuses the
inbound queue/steering of the messaging connectors). - (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 inPERMISSIONS-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. SeePERMISSIONS-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 arecode-family. - 2026-06-27 โ Code stays a persona (canonical
code-family persona);familyis 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 undercodefamily. - 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
UX-DECISIONS.mdโ the why + the binding UX decisions. Do not change
documented UX without the owner's (Rohit's) sign-off.UI-REFRESH-SPEC.mdโ the what/how (full-stack), with build phases 1โ5.FAKE-SLACK-SPEC.mdโ build this first (a separate sub-agent); the
integration tests depend on it.UI-REFRESH-VERIFICATION.mdโ the test cases + acceptance gate.- Visual reference:
../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 oforigin/main). Local-only โ do NOT push; Rohit pushes. - Commits: author and committer
Devika <[email protected]>, 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 --noEmitclean +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.mdand commit it (that commit is the handoff).
That ledger is the shared running log for the wholeplatform/personasbranch โ 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 adapterSLACK_API_URLoverride. 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
sourcesidecar (stripped
before the provider). - Placeholder
available:falsedescriptors for not-yet-shipped recommended connectors (GitHub,
Datadog, โฆ) so the UI renders their badge + Connect. - Persona default connections seed from manifest
recommendswheretier:coreAND connected. - One production change for FakeSlack: a
SLACK_API_URLbase-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(the why; don't violate it). - Visual spec:
../ui-mocks/redesign.html(the look). - Tests + acceptance:
UI-REFRESH-VERIFICATION.md. - Test harness:
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
- 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). - 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. - 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). - Names everywhere, ids on hover. Resolve + cache
id โ display namefor 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 toConnectorDescriptor: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_wayas appropriate, noconnect.)
- โ
coworker/connectors/setup.py::connector_list: includebrand_color,logoper connector.
Frontend
- โ
surfaces/gui/src/connectors/registry.ts(new):CONNECTORS: Record<string, {label; logo}>
mappinglogoid โ an imported SVG component, plusFALLBACK. A<ConnectorIcon connector=โฆ/>
and<ConnectorBadge/>component readsbrand_colorfrom API data and the SVG from the registry;
unknown id โ fallback plug glyph + the provided/neutral color. (Mirror the mock'sCONNECTORS
object +data-brandtinting.) - 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 / InteractionEventSlack already implements connect/disconnect/send/_display_name; add send_interactive,update_message (currently on senders/gateway โ keep, but route through the adapter), andresolve_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: extendSessionSourcewithchat_name: Optional[str] = None
(channel/DM display name; user_name already exists).MessageEventalready carriestext,source,message_id(the ts). Adapters MUST populateuser_name+chat_name(resolved, ยง2.3)
before handing the event to the gateway. _dispatch_inbound(ยง3) builds the structuredMessageSourcefrom 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-memorydict, 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 storesuser_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 newmessages.py):MessageSourcedataclass:textconnector: 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
contentstays 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(theMessageSource) 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 parallelmessage_meta[index]or anocw_sourcekey that the provider serializer drops. Pick whichever
matches the current message model with least friction; document the choice in the PR.
- The model-facing
3.2 Plumbing
- โ
coworker/server/manager.py:deliver_to_session(session_id, message, *, source: MessageSource | None = None). Whensource
is set, the engine records the user message with the framed text and the source sidecar._dispatch_inbound: buildMessageSource(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_STARTeventdataincludessource(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 includessourcewhen present.- WS
turn_start: includesourcein the eventdata(already forwardsinput).
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 optionalsource. InitemsFromMessagesand theturn_starthandler, when a user message hassource.connector, push a{ kind: "connector", source }item rendered byConnectorMessageCardinstead 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
- account-connected โ a connector profile with valid creds exists (
secrets[<name>:default]).
Unchanged (connector_list[].connected). - persona-default-enabled โ per persona, which connected connectors are on by default for its
sessions. User-editable (persona detail page), seeded from the manifest. - 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 โ arecommendsitem withtier: corewhose
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 โ readagent.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}](annotateconnectedfromconnector_list), anddefault_connections: [{connector, enabled, connected}](fromPersonaConnectionStore.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").enabledreflects the session override/persona
default so the drawer toggle shows correct state.recommended: [{connector, reason, tier, connected}]โ the persona'srecommendsnot yet
connected (drives theโ Ncount =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 = sendenabledmatching the persona default, or add aclearflag.
Frontend โ surfaces/gui/src/components/SourcesBar.tsx + SourcesDrawer.tsx (new):
- Sources bar: icons-only avatar stack +
โ Nbadge (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 existingtool_*/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/POSTthe existing settings/prefs; addnav_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/logofields; addMessageSource,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)
- Foundation: connector registry metadata (ยง1) + connector contract tidy-up (ยง2, incl.
resolve_channel_name). Land FakeSlack (separate deliverable) so later phases test deterministically. - Structured messages (ยง3) โ backend +
ConnectorMessageCard. High user-visible value. - Connection hierarchy (ยง4) โ stores + resolver + runtime gating. The core data-model change.
- Persona + session surfaces (ยง5, ยง6) โ APIs + PersonaView + SourcesBar/Drawer.
- 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. 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) โ
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) andnpm 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=โฆ)andTestClient(create_app(mgr))(seetest_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. NoCOWORKER_DEBUG_INJECT, no real tokens.
Phase 1 โ connector registry + contract
Unit
test_descriptor_brand_logo: every descriptor exposesbrand_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 asavailable:falseso personas can recommend them; they have noconnect.test_slack_resolve_channel_name:_channel_name(id)returns the resolved name, caches (oneconversations.infocall for repeats), returnsNoneon failure. (Pattern mirrors the existingtest_slack_resolves_and_caches_display_name.)
Frontend
ConnectorIcon/ConnectorBadgerenders 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_inboundof a channelMessageEvent(withuser_name,chat_nameset) delivers to the subscribed session with aMessageSource
(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 withsource; the provider received the framed text and nosource/unknown keys (assert against theScriptedProvider's capturedmessages).test_turn_start_carries_source: the WSturn_startevent for a delivered connector message
includessource(register a fake session client, liketest_session_events.py).test_dm_message_source_kind_dm: a DM yieldskind="dm".
Frontend
itemsFromMessagesmaps a message withsource.connectorto aconnectoritem;turn_startwithsourcerendersConnectorMessageCard(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: withconnected={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_inboundfor 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/opsreturns identity, tools,recommendswithconnectedannotated, anddefault_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/personaspicker list.test_session_connections_endpoint:GET /v1/sessions/{id}/connectionsreturns 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 +GETreflect 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 --noEmitclean;npm run buildsucceeds.
Cross-cutting acceptance (end-to-end, via FakeSlack)
A single scripted scenario the FakeSlack harness can drive, asserting the whole refresh:
- Connect Slack (against FakeSlack); allow a user; subscribe the Ops "incident" session to a channel.
- Post a channel message โ session shows the connector card live (resolved names).
- 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). - Mute Slack for the session in the drawer โ a further channel post does not wake it.
GET /v1/sessions/{id}/connectionsattentionmatches 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 --noEmitclean;npm run buildpasses.
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โช Proposednote 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 inUI-REFRESH-SPEC.md (+ UI-REFRESH-VERIFICATION.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. Slackusers.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_fileapproval)
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):
- 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). - Connected sources for this session (with live status and an enable/disable toggle โ see ยง4).
- 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. - A link out to global Integrations.
- A why-connect blurb for the session's persona ("Ops works best wired into GitHub, Slack, a
- 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 +
โ Nbadge 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
recommendslist: each item references aconnector:ormcp:, with areason(the value it unlocks) and atier(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:
recommendsis 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:toolsARE 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:- Flat โ Pinned + Recent (current).
- 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 manifestrecommends, ยง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โ Nnudges 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_wayread 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 (oldAuditView) moves onto the same page shell, dropping the legacypage-view
layout and its duplicate header. - Rationale: The modal was the last top-tab surface and the last
page-viewstraggler, 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 tosurface: "settings"with an initial section. The
shared tab bodies (ModelsTab,ConnectorsTab,McpTab) moved toManageTabs.tsx; deadSettingsTab/AuditTab/ManageModal/UnattendedToggleremoved. - 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-*); syncredesign.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-flaggedfeaturedcards, 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 existingtenant_onlyvisibility. 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
workspaceenum (git | project | deliverable | none) is retired as a
behavioral axis.familyalone 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 keepsnonevia 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: truerefinement 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'smodelbinds the engine; later message models andset_modelare 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 separateset_modelhandoff 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;readyoverwriting 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):
- 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). - 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. - 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).
- Collapse (โB) with hover-peek. The nav can collapse so the content reclaims the width
- 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, mockUX-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:
- The creation consent card (already exists โ
create_scheduled_taskis 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 newpermissionsfield on the create-tool schema; the existing card renders
it. Rejected: the agent writingconfig.tomlโ model-authored permission expansion in a
global file, invisible in UI, outlives the automation. - "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.
- The creation consent card (already exists โ
- 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, thepermissionscreate-field, the task-persistent Allow-every-time, and liftingpermissions.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:
- 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. - Create a report from my HubSpot leads โ gated on HubSpot.
- 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).
- Pending (default): approvals/questions exactly as before (kind chips, persona chips,
- 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:
- Model โ unchanged (ยง24 + its revisions).
- 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. - 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 connectingin 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_pagein 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 viaprefers-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 + aGET /me/connectionsrestore 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 inmention_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_messagegrant (ยง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_labeland 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_shellis the
exception and the model DOES speak there: itsdescriptionargument (model-written intent,
ยง25 lineage) is preferred โ "Rangit 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 runrm -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'srunningflag). 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.mdCSS 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-writtenpurposeon 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 ashow_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_filetool (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 standingsend_messagegrant 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>.pnginstead. 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): "Writefetch_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, inlinepreview โพ, 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 / Runbrowser_read_url? / solid-blue Approve". Parked items now
always carrytool+argumentsin 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, โฆ) carryrequires_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
classifiesbrowser_open_urlandgithub_clone/pullas reads โ they ride the rule. - "Post Hi to #general" works.
send_message/send_fileaccept a Slack channel NAME
(lowercase or#-prefixed โ Slack names are strictly lowercase, ids are uppercaseCโฆ/Tโฆ/โฆ, so the shapes never collide) and resolve it through the same cachedconversations.listroster 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_channelfrom Slack maps to the invite hint too. โช Open question (owner, parked):platform-specific tools (send_slack_message) instead
of the genericsend_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, withneeds microphone permission,test required, anderroras 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 downloadsggml-base.en.binon 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
connectedand 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 intosrc/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_urlon 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 โ flipTOOLS_SOONwhen 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 staysappearanceso
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.tsshowPersonas(), 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
toproviders/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 prefixset-, onboarding keepsob-. - 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 intool_defs(namesmcp__<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_toolson 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'smode: "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.
- Asana + Jira built same day). Tool-def scaling (caching audit, deferred tools + search)
- 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 inui-mocks/voice-input-settings.htmlandui-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_PANEflag 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, notslack: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 apermissionsfield) 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 keeppersona. - 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:
โ Nbadge (not "N recommended"); per-session enable/disable toggle
(hierarchy ยง4); richer manifests withrecommends(ยง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
- New connector
email("Email (IMAP)"). The existinggmail/outlookAPI
connectors stay untouched;emailbecomes 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. - Stdlib only:
imaplib+smtplib+email. No new dependency (validated by
Hermes' production gateway, which is pure stdlib). Tool functions are sync like the
rest ofintegration_tools.py; one fresh connection per call, no pooling, no IDLE. - 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 existingFieldmodel (all text inputs) โ
no setup-wizard UI changes needed. - 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"[email protected] ยท 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 intomake_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 viaBODY.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'sMessage-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