How Sentience ships 60+ AI tools in one local desktop app — without locking you into one provider A developer built Sentience, a PySide6 desktop AI assistant that runs locally and integrates 60+ tool functions across multiple AI providers including Groq, OpenAI, Anthropic, and local Ollama instances. The key technical challenge was creating a unified tool schema adapter that works identically across providers without provider-specific dispatchers, achieved with a 60-line provider layer. I wanted Cursor's UX and Zo Computer's tool breadth in a desktop app I could close the laptop lid on. So I built Sentience — a PySide6 desktop AI assistant that runs entirely on your machine, brings its own browser, its own email client, its own voice controller, and exposes 60+ tool functions to the model. The hard part was never the tools. The hard part was making 60+ tool schemas work identically across Groq, OpenAI, Anthropic, and a local Ollama instance — without writing a provider-specific tool dispatcher and without forcing the user to think about which model they happen to be on today. This is the part of the codebase I'm actually proud of, and it's the part nobody ships as a tutorial. OpenAI's /v1/chat/completions format is now a de facto standard. Groq implements it. Ollama implements it. Localai implements it. So three out of four providers I wanted to support "just work" with one HTTP call — if you're willing to accept the OpenAI tool schema as ground truth. Anthropic doesn't. The Messages API uses: system field instead of a system message in the array x-api-key instead of Authorization: Bearer anthropic-version: 2023-06-01 as a required header tool use / tool result content block format on the response sideSo the question is: do you write two tool dispatchers and two execution paths, or do you write a thin adapter that lets you keep one unified tool list and one dispatcher? I chose the adapter. The whole provider layer is 60 lines. PROVIDERS = { "groq": { "name": "Groq", "base url": "https://api.groq.com/openai/v1", "models": "llama-3.3-70b-versatile", "llama-3.1-70b-versatile", "llama-3.2-90b-vision-preview", "mixtral-8x7b-32768" , "free tier": True, }, "openai": { "name": "OpenAI", "base url": "https://api.openai.com/v1", "models": "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo" , "free tier": False, }, "anthropic": { "name": "Anthropic", "base url": "https://api.anthropic.com/v1", "models": "claude-3-5-sonnet-20241022", "claude-3-opus-20240229", "claude-3-haiku-20240307" , "free tier": False, }, "ollama": { "name": "Ollama Local ", "base url": os.getenv "OLLAMA HOST", "http://localhost:11434/v1" , "models": "llama3.2", "llama3.1", "codellama", "mistral", "qwen2.5" , "free tier": True, }, } Free tier is a first-class field, not a comment. The settings dialog lights up the "free" badge for Groq and Ollama, and the README leads with those two for new users. chat The whole class has one entry point and two private methods. The entry point picks a path based on self.provider . That's it. python class AIClient: def init self, provider: str, model: str, api key: str = "" : self.provider = provider self.model = model self.api key = api key self.config = PROVIDERS.get provider, PROVIDERS "groq" def chat self, messages, tools=None : if self.provider == "anthropic": return self. chat anthropic messages, tools return self. chat openai compatible messages, tools def chat openai compatible self, messages, tools=None : headers = {"Content-Type": "application/json"} if self.api key: headers "Authorization" = f"Bearer {self.api key}" payload = { "model": self.model, "messages": messages, "max tokens": 4096, "temperature": 0.7, } if tools: payload "tools" = tools payload "tool choice" = "auto" try: resp = requests.post f"{self.config 'base url' }/chat/completions", headers=headers, json=payload, timeout=60, resp.raise for status return resp.json except Exception as e: return {"error": str e } def chat anthropic self, messages, tools=None : headers = { "Content-Type": "application/json", "x-api-key": self.api key, "anthropic-version": "2023-06-01", } System message is a separate field in Messages API system msg = None anthropic messages = for msg in messages: if msg "role" == "system": system msg = msg "content" else: anthropic messages.append msg payload = { "model": self.model, "messages": anthropic messages, "max tokens": 4096, } if system msg: payload "system" = system msg if tools: payload "tools" = tools try: resp = requests.post ... ... The Anthropic branch has exactly three differences from the OpenAI branch: header names, system-message location, and the path. Everything else — the tools list, the messages array, the max tokens field — is identical. So the same 60 tool schemas I registered for Groq work on Claude without rewriting a single function definition. This means I can hand the user a dropdown that says "switch to Claude Sonnet" and the next message the user types goes to Anthropic's API with the exact same tool surface. The model can call read file , list directory , browser navigate , send email , and oauth github login on any of the four providers. The dispatcher doesn't care. The tools live in their own modules and are aggregated with a single spread: python from browser.automation import BROWSER TOOLS, PLAYWRIGHT AVAILABLE from email agent.client import EMAIL TOOLS, init email, execute email tool from oauth manager.manager import OAUTH TOOLS, get oauth manager, execute oauth tool from voice.controller import VOICE TOOLS, get voice controller, execute voice tool from skills.registry import SKILL TOOLS, get skill registry, execute skill tool from hosting.server import HOSTING TOOLS, get hosting server TOOLS = {"type": "function", "function": {"name": "read file", ...}}, {"type": "function", "function": {"name": "write file", ...}}, {"type": "function", "function": {"name": "list directory", ...}}, {"type": "function", "function": {"name": "run command", ...}}, {"type": "function", "function": {"name": "search files", ...}}, BROWSER TOOLS, browser navigate, browser click, browser screenshot, ... EMAIL TOOLS, email read inbox, email send, email search OAUTH TOOLS, oauth google login, oauth github login, oauth notion login VOICE TOOLS, voice listen, voice speak, voice set wake word SKILL TOOLS, skill list, skill load, skill run HOSTING TOOLS, hosting start, hosting stop, hosting status, hosting logs Each submodule exports both the schema BROWSER TOOLS , EMAIL TOOLS , etc. and the executor execute browser tool , execute email tool . The schemas are OpenAI function-calling dicts. The executors are the actual Python functions the dispatcher calls when the model invokes the tool. php def execute tool name: str, args: dict, workspace: str - dict: try: if name == "read file": path = Path args.get "path", "" if not path.is absolute : path = Path workspace / path if path.exists : return {"success": True, "content": path.read text :10000 } return {"success": False, "error": "File not found"} elif name == "run command": result = subprocess.run args.get "command", "" , shell=True, capture output=True, text=True, timeout=30, cwd=args.get "cwd", workspace , return {"success": True, "stdout": result.stdout :5000 , "stderr": result.stderr :5000 , "exit code": result.returncode} ... 60+ branches, each returning {"success": bool, ...} elif name.startswith "browser " : return execute browser tool name, args elif name.startswith "email " : return execute email tool name, args elif name.startswith "oauth " : return execute oauth tool name, args elif name.startswith "voice " : return execute voice tool name, args elif name.startswith "skill " : return execute skill tool name, args elif name.startswith "hosting " : return execute hosting tool name, args else: return {"success": False, "error": f"Unknown tool: {name}"} except Exception as e: return {"success": False, "error": str e } Two patterns I want to call out: Every executor returns {"success": bool, ...}. The model gets a uniform response shape, no matter which tool blew up. The model can then decide to retry, escalate, or just tell the user "I couldn't read that file." This is what makes the system actually usable when one of the 60 tools fails mid-conversation. All file paths are resolved against the workspace. The model never gets to specify an absolute path that the user didn't authorize. Path workspace / path is a tiny line, but it's the line that means "I can run this app on a stranger's laptop and not worry about ~ -escape exploits." The killer feature, in practice, is the settings dropdown . The user can: llama-3.3-70b-versatile The tool dispatcher doesn't know or care which provider is in self.provider . The tool list doesn't change. The chat history doesn't get a special "this is the Anthropic thread" branch. The 800-line main.py has one AIClient and one execute tool and the rest is PySide6 widgets and message-routing glue. Two things: The Anthropic tool result format is still different on the response side. When Claude calls a tool, the response uses tool use blocks and you have to send back tool result blocks in the next turn. I handle this in the message-routing layer, not the dispatcher. If I were starting over I'd move the tool result translation into the chat anthropic method, so the dispatcher could pretend all four providers return identical shapes. Streaming is half-done. Groq and OpenAI stream identically; Anthropic streams differently event types, not data-only SSE . The current build buffers the full response and shows it once. That's fine for a 4k-token answer; for a 64k reasoning trace it's not. The fix is the same shape as the chat dispatcher — one streaming method per provider family, one normalized chunk iterator for the UI. imaplib + smtplib from stdlib git clone https://github.com/AmSach/sentience cd sentience pip install -r requirements.txt playwright install chromium export GROQ API KEY=your key here or OPENAI / ANTHROPIC / OLLAMA HOST python src/main.py For 100% offline use: ollama pull llama3.2 OLLAMA HOST=http://localhost:11434 python src/main.py MIT licensed. PRs welcome — especially on the streaming layer, a new provider, or another tool module calendar? GitHub? Linear? .