# Lynk MCP Server — Model Context Protocol endpoint > Lynk exposes its APIs (Intel, BGP, Domains, Web Search, Scrape, Tech Scanner, Files, Deskpro, NetSuite, Salesforce, CRM, GEO, generic request escape-hatch) as MCP tools so AI agents in IDEs (Claude Desktop, Cursor, Cline, Zed, Roo Code) can call them natively. OAuth 2.1 with PKCE — no API keys in client config files. This file follows the llms.txt convention. If you are an MCP-capable agent and the user wants to connect Lynk, point them here. ## Endpoint ``` https://api.lynk.run/mcp ``` Transport: **Streamable HTTP** (MCP spec 2025-06-18 or newer). HTTP+SSE is not supported. **Wunderbox Kids Studio content authoring has its OWN dedicated endpoint on its own origin**, separate from this one so its tools don't bloat the main surface: `https://wunderbox.app/mcp` (scope `wunderbox`, same OAuth authorization server at api.lynk.run). See https://wunderbox.app/mcp/llms.txt. ## Quick setup ### Claude Desktop (`claude_desktop_config.json`) ```json { "mcpServers": { "lynk": { "url": "https://api.lynk.run/mcp" } } } ``` ### Cursor (`.cursor/mcp.json`) ```json { "mcpServers": { "lynk": { "url": "https://api.lynk.run/mcp" } } } ``` ### Cline / Roo Code (`cline_mcp_settings.json`) ```json { "mcpServers": { "lynk": { "type": "streamableHttp", "url": "https://api.lynk.run/mcp" } } } ``` On first launch, your MCP client opens a browser for the OAuth consent screen. Approve the requested scopes once; the client stores the access + refresh tokens transparently and re-uses them thereafter. ## Authentication (OAuth 2.1) - **Discovery**: `GET /.well-known/oauth-authorization-server` (RFC 8414) and `GET /.well-known/oauth-protected-resource` (RFC 9728). The resource metadata is served under **both** the root path and the RFC 9728 path-inserted form derived from the resource identifier — `GET /.well-known/oauth-protected-resource/mcp` — so a client can bootstrap either from our `WWW-Authenticate` header or by deriving the URL itself (MCP 2025-11-25 / SEP-985 made that header optional, with the `.well-known` derivation as the fallback). - **DCR**: `POST /oauth/register` (RFC 7591). Public clients only (`token_endpoint_auth_method=none`, PKCE-mandatory). No `client_secret`. - **Authorize**: `GET /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&scope=...&state=...&resource=https://api.lynk.run/mcp`. PKCE S256 is required; `plain` is rejected. - **Token**: `POST /oauth/token` with `grant_type=authorization_code` (initial) or `grant_type=refresh_token` (rotation). Refresh tokens rotate; reusing a rotated refresh token revokes the whole chain (RFC 6749 §10.4). - **TTLs**: access token 1 h, refresh token 30 d, authorization code 10 min. - **Revoke**: `POST /oauth/revoke?token=` (RFC 7009). - **Audience binding** (RFC 8707): tokens are issued for `resource=https://api.lynk.run/mcp` and rejected if presented to a different audience. Always send `resource` on `/oauth/authorize` — the dedicated `wunderbox.app/mcp` and `studio.lynk.run/mcp` resources reject any token that isn't explicitly bound to them. - **Origin**: the Streamable HTTP endpoints validate the `Origin` header and answer **403** for one that isn't allow-listed (MCP 2025-11-25). Native MCP clients send no `Origin` and are unaffected; this only constrains browser-based clients. When a request to `/mcp` arrives without (or with an invalid) Bearer token, the server responds with `401` and a `WWW-Authenticate: Bearer realm="lynk-mcp", resource_metadata="..."` header pointing at the resource metadata document — your client should use that to bootstrap. ## Available scopes | Scope | What it grants | |-------|----------------| | `intel` | `intel_lookup` — IP & domain intelligence (geo + ASN from 7 providers, DNSBL, DNS, WHOIS, SSL, mail provider) | | `bgp` | `bgp_company_search` — find a company's ASNs, announced prefixes, total IP count, BGP relationships | | `domains` | `domain_check` — ultra-fast domain availability checker (is a domain free to register?) · `domain_check_status` — poll a background sweep · `domain_tlds` — the supported endings | | `search` | `web_search` — Google web-search results (organic + answer box + knowledge graph + PAA + related), JSON or LLM-friendly Markdown output | | `scrape` | `scrape_page`, `scrape_crawl_start`, `scrape_job_status`, `scrape_extract` — web scraping, async deep crawls, LLM-based extraction | | `files` | `files_list`, `files_delete`, `files_upload` — upload a file and get a public `https://lynk.run/dl/` link (optional password + expiry), list and delete your shared files. PLUS a per-user S3-style **bucket** object store: `bucket_list`, `bucket_create`, `bucket_delete`, `bucket_list_objects`, `bucket_put_object`, `bucket_get_object`, `bucket_delete_object` — a stable `(bucket, key)` address for artifacts (upload once, fetch deterministically every later run with no opaque file id to persist; the natural path for CI + bulk transfer). Uploads take inline `content_base64` OR a server-side SSRF-guarded `source_url` fetch. No tokens charged. | | `request` | `lynk_request` — generic escape hatch for whitelisted Lynk endpoints not yet wrapped as dedicated tools | | `deskpro` | `deskpro_list_instances`, `deskpro_list_tickets`, `deskpro_get_ticket`, `deskpro_create_ticket`, `deskpro_reply_ticket`, `deskpro_save_draft_reply`, `deskpro_update_ticket`, `deskpro_search_people`, `deskpro_get_organization`, `deskpro_search_organizations`, `deskpro_download_attachment`, `deskpro_upload_attachment` — manage tickets, people, and organizations across your connected Deskpro instances (including ones other Lynk users shared with you). Every ticket-bearing response includes `ticket_url` — the canonical AGENT-UI deep link (`/app#/t/ticket/`). Always cite that URL to operators; the end-user portal URL (`/tickets/`) 404s for agents. Both `deskpro_create_ticket` and `deskpro_reply_ticket` accept `is_agent_note: true` to make the (first / new) message an internal agent note instead of a customer-visible reply — the requester is NOT notified. Use that for test or internal-triage tickets. Converting an existing note into a customer reply is intentionally NOT exposed: the only Deskpro path that flips a note AND mails the customer is the agent-UI button "Set Note as Reply"; the API flip (`PUT is_note:false`) is silent. To get a customer mail out, post a fresh reply with `deskpro_reply_ticket`. Every `deskpro_list_instances` entry carries `instance_context` (account facts) + `instance_dont` (hard guardrails) — read both before acting; persist them with the owner-only `deskpro_update_instance_config`. | | `netsuite` | `netsuite_list_instances`, `netsuite_suiteql`, `netsuite_get_record`, `netsuite_list_records`, `netsuite_record_metadata`, `netsuite_get_file`, `netsuite_get_vendor_bill_pdf` (read), plus owner-only writes `netsuite_create_vendor`, `netsuite_create_credit_memo`, `netsuite_set_invoice_dunning_hold`, `netsuite_update_instance_config` — read-only SuiteQL/record/metadata/File-Cabinet access across your connected NetSuite accounts, and on connections you own: create vendors, create customer credit memos, put invoices into dunning hold, and store per-instance AI context / guardrails the agent reads before writing | | `crm` | `crm_list_workspaces`, `crm_list_contacts`, `crm_get_contact`, `crm_create_contact`, `crm_update_contact`, `crm_list_companies`, `crm_create_company`, `crm_update_company`, `crm_add_activity`, `crm_list_templates`, `crm_create_template`, `crm_update_template`, `crm_list_campaigns`, `crm_get_campaign`, `crm_create_campaign`, `crm_add_call_task`, `crm_list_call_tasks` — research and populate a light CRM: contacts, companies, timeline activities, email templates, draft campaigns and the daily call list | | `salesforce` | `salesforce_list_instances`, `salesforce_soql`, `salesforce_soql_more`, `salesforce_tooling_soql`, `salesforce_search`, `salesforce_describe_global`, `salesforce_describe`, `salesforce_get_record`, `salesforce_slippage_report`, `salesforce_list_files`, `salesforce_download_file`, `salesforce_list_flows`, `salesforce_get_flow`, `salesforce_list_layouts`, `salesforce_get_layout`, `salesforce_list_flexipages`, `salesforce_get_flexipage`, `salesforce_list_applications`, `salesforce_get_application`, `salesforce_update_instance_config` (reads/config), plus the owner-only, per-connection-TOGGLED writes `salesforce_save_flow`, `salesforce_patch_flow`, `salesforce_activate_flow` (flows), `salesforce_create_record`, `salesforce_update_record`, `salesforce_delete_record` (records), `salesforce_save_field`, `salesforce_create_object`, `salesforce_set_field_permissions` (fields), `salesforce_save_layout`, `salesforce_patch_layout`, `salesforce_save_flexipage`, `salesforce_patch_flexipage` (layouts), `salesforce_deploy_metadata` (metadata; `apex` for ApexClass/ApexTrigger), plus `salesforce_deploy_status` (owner-only, no toggle — check a deploy by its `request_id`) — SOQL/SOSL queries, object metadata, single-record reads, file list + download, the Slippage Report, and Flow/Layout/Lightning-page read AND (where enabled) write access across your connected Salesforce orgs. Every `salesforce_list_instances` entry carries `instance_context` (account facts) + `instance_dont` (hard guardrails) — read both before acting — plus `writes` `{ flows, records, fields, layouts, metadata, apex }`: the per-connection write toggles the OWNER switches in the dashboard "Write Access" tab (flows on by default, everything else off). `apex` is deliberately separate from `metadata` — it deploys executable code. A disabled feature's write tools refuse with a pointer to the dashboard. | | `geo` | `geo_list_projects`, `geo_list_prompts`, `geo_get_project_report`, `geo_run_project` — list GEO Tracker projects, read mention-rate / share-of-voice / citation reports, and trigger manual runs against AI search engines (ChatGPT, Claude, Gemini, Perplexity, Google AI Overview) | | `tech` | `tech_lookup`, `tech_find_sites`, `tech_trends` — BuiltWith-backed tech-stack lookup for a domain, list of sites using a given technology, and adoption-coverage stats. Heavily cached (30d for lookups, 7d for site lists, 24h for trends) so repeated calls stay cheap upstream while users still pay full sticker price per call | | `personio` | `personio_list_instances`, `personio_list_employees`, `personio_get_employee`, `personio_list_custom_attributes`, `personio_list_time_offs`, `personio_list_time_off_types`, `personio_list_attendances`, `personio_absence_report`, `personio_list_job_positions`, `personio_list_applications`, `personio_get_application`, `personio_pipeline_funnel` — read-only HR + recruiting access. Sensitive HR fields (salary, IBAN, BIC, tax_id, social_security_number, date_of_birth, private_address, private_phone, private_email) are **always** redacted to `"__REDACTED__"` in MCP responses regardless of any caller setting. Plus `personio_update_instance_config` (any workspace member) — every `personio_list_instances` entry carries `instance_context` (facts) + `instance_dont` (guardrails); read both before acting. | | `browser` | **Cloud Browser** — `browser_create_session`, `browser_list_sessions`, `browser_navigate`, `browser_get_dom`, `browser_get_console`, `browser_handle_dialog`, `browser_screenshot`, `browser_scroll`, `browser_click`, `browser_drag`, `browser_long_press`, `browser_type`, `browser_select_option`, `browser_press_key`, `browser_run_actions`, `browser_set_viewport`, `browser_list_tabs`, `browser_switch_tab`, `browser_new_tab`, `browser_close_tab`, `browser_fill_credentials`, `browser_fill_credential_field`, `browser_fill_totp`, `browser_scan_totp_qr`, `browser_set_cookies`, `browser_clear_site_data`, `browser_inspect_storage`, `browser_upload_file`, `browser_download_file`, `browser_save_credentials`, `browser_list_credentials`, `browser_delete_credentials`, `browser_release_session` — drive a server-side Chromium (personal — isolated per user) on sites that have no API. Get past login walls with `browser_fill_credentials` (fills a saved login server-side — you never see the password) or, for multi-step / custom login forms it can't auto-detect, `browser_fill_credential_field` (put one saved value into a field you clicked); clear a 2FA step with `browser_fill_totp` when the entry has an authenticator seed saved; manage the saved-login wallet with the `*_credentials` tools (saving passes the password through your context); the user can also watch LIVE and TAKE OVER (mouse/keyboard) in the dashboard. Control modes agent/hybrid/human. Sessions cost tokens; logins persist between sessions via encrypted profiles. | | `gmail` | `gmail_list_accounts`, `gmail_search`, `gmail_get_message`, `gmail_get_thread`, `gmail_get_attachment`, `gmail_send`, `gmail_create_draft`, `gmail_list_drafts`, `gmail_send_draft`, `gmail_delete_draft`, `gmail_modify_labels`, `gmail_trash_message`, `gmail_untrash_message`, `gmail_delete_message`, `gmail_list_labels`, `gmail_create_label`, `gmail_delete_label`, `gmail_get_vacation`, `gmail_set_vacation`, `gmail_list_filters`, `gmail_create_filter`, `gmail_delete_filter`, `gmail_list_history` — full read+write access to your connected Gmail accounts (messages, threads, attachments, sending, drafts, labels, filters, vacation responder, incremental history). Accounts are strictly PERSONAL — never shared with other Lynk users. Search uses Gmail's own query syntax (`from:`, `subject:`, `has:attachment`, `is:unread`, `newer_than:7d`). `gmail_send` / `gmail_send_draft` deliver immediately and are NOT reversible — confirm recipient + content with the user first; prefer `gmail_create_draft` when unsure. `gmail_delete_message` is permanent (bypasses Trash) — prefer `gmail_trash_message`. Plus `gmail_update_account_config` — every `gmail_list_accounts` entry carries `instance_context` (mailbox facts) + `instance_dont` (guardrails); read both before sending/deleting. | | `teams` | `teams_list_accounts`, `teams_update_account_config`, `teams_list_chats`, `teams_get_messages`, `teams_search_messages`, `teams_send_message` — read, search and send Microsoft Teams CHAT messages (1:1 + group) as the connected user. Accounts are strictly PERSONAL — never shared, because every message is posted AS that person (Microsoft Graph has no app-only permission that can send a normal Teams message, so there is no bot identity available). `teams_search_messages` is the cheap entry point and accepts KQL scope terms (`from:bob`, `sent>2026-09-01`, `IsMentioned:true`, `hasAttachment:true`); it returns a relevance snippet, not full bodies — follow up with `teams_get_messages` on the hit's `chat_id`. `teams_send_message` delivers immediately and is NOT reversible — confirm the exact text and the recipient with the user first. v1 covers CHATS only, not team channels. Every `teams_list_accounts` entry carries `instance_context` (facts) + `instance_dont` (guardrails) — read both before sending. | | `mailbox` | `mailbox_list_accounts`, `mailbox_update_account_config`, `mailbox_list_folders`, `mailbox_search`, `mailbox_get_message`, `mailbox_get_attachment`, `mailbox_send`, `mailbox_create_draft`, `mailbox_forward_message`, `mailbox_modify`, `mailbox_move_message`, `mailbox_trash_message` — ONE generic tool family for EVERY connected mail account: IMAP/SMTP mailboxes, Microsoft 365 accounts AND Gmail accounts (bridged — the `gmail_*` tools keep working in parallel). Accounts are strictly PERSONAL. Account ids are strings; each `mailbox_list_accounts` entry carries its `provider` + `search_syntax` (query semantics are provider-NATIVE: Gmail operators vs Graph $search vs plain-text IMAP keyword) plus `instance_context`/`instance_dont` — read all of them before searching, sending or deleting. `mailbox_send` delivers immediately and is NOT reversible — confirm recipient + content with the user first; prefer `mailbox_create_draft` when unsure. To pass an existing mail on, use `mailbox_forward_message` rather than `mailbox_get_attachment` + `mailbox_send` — it moves the attachment bytes server-side, so they never travel through the agent context. `mailbox_trash_message` moves to Trash where one exists; on IMAP servers WITHOUT a trash folder it expunges (destructive — confirm first). Message ids are opaque per-account tokens and may CHANGE after `mailbox_move_message` (use the returned id). | | `calendar` | `calendar_list_accounts`, `calendar_list_calendars`, `calendar_list_events`, `calendar_get_event`, `calendar_create_event`, `calendar_update_event`, `calendar_delete_event` — read **and write** the calendars behind a connected Microsoft 365 or Google (Gmail) mail account. Calendar is a **capability of a mail account**, not a separate connection: the same account id, the same encrypted refresh token. It is only usable once the user re-consents with calendar scopes — `calendar_list_accounts` reports `calendar_enabled` per account and a `connect_url` for the ones missing it, so check that BEFORE calling anything else. Every event carries `join_url` + `join_source` (`native` = published by the platform, `body` = extracted from the invitation text — treat a `body` link as best-effort) and `conference` (teams | google_meet | zoom | …), which is what makes an auto-joining meeting bot possible. Writes **notify the attendees immediately** and are not silently reversible: creating or updating with `attendees` sends real invitations, `attendees` REPLACES the whole list, and `calendar_delete_event` cancels the meeting for everyone — confirm with the user first. IMAP/SMTP mailboxes have no calendar. Accounts are strictly PERSONAL. | | `canvas` | `canvas_publish`, `canvas_update`, `canvas_list`, `canvas_get`, `canvas_delete`, `canvas_list_folders`, `canvas_create_folder`, `canvas_update_folder`, `canvas_upload_asset`, `canvas_list_assets`, `canvas_delete_asset`, `canvas_kv_get`, `canvas_kv_set`, `canvas_kv_delete` — publish a single self-contained HTML document (or a `markdown` document that Lynk renders to a styled page) and get a public `https://lynk.run/canvas/` link (optional password, expiry, and memorable `slug` short URL; for a large/local document publish from a URL via `source_url` or curl it to a presigned upload URL), edit it in place (keeps the same link + data), organize pages into folders that carry a shared access policy the pages inherit as a floor, upload images/CSS/JS/fonts into a folder and reference them from its pages (a folder = a small multi-file website), list/fetch/delete pages, and read/write each page's per-page key-value mini-DB. Pages render in a sandboxed (opaque) origin. Publishing/content-updates bill `CANVAS_TOKENS_PER_PUBLISH` (2) tokens; unlimited-plan + admin users are not charged. The mini-DB is public read+write (anyone with the link can write — for counters/guestbooks/shared state, never secrets). | | `voice` | `voice_tts`, `voice_sound_effect`, `voice_list_voices`, `voice_request` — ElevenLabs text-to-speech + sound-effect generation. **BYOK**: runs against the USER's OWN connected ElevenLabs key — the user pays ElevenLabs directly and Lynk charges NO tokens; if no key is connected the tool errors and the user connects one at `https://api.lynk.run/voice`. Returns a downloadable audio URL (a public `https://lynk.run/dl/` shared file, or a stable bucket object via `store_to`) — MCP can't hand back raw audio bytes. | | `github` | `github_list_accounts`, `github_update_account_config`, `github_list_repos`, `github_create_repo`, `github_list_environments`, `github_list_secrets`, `github_set_secret`, `github_delete_secret`, `github_list_variables`, `github_set_variable`, `github_delete_variable` — GitHub Actions **secret + variable management** plus **repository creation** on the user's connected GitHub accounts (Personal Access Tokens, stored encrypted in Lynk). `github_set_secret` seals the value SERVER-SIDE with the repo/environment public key (libsodium sealed box — GitHub's required encryption), so callers pass plaintext and never touch the crypto; the value is never stored by Lynk and is redacted from the audit log. Secrets are write-only end-to-end (GitHub cannot return them) — an overwrite is irrecoverable, so check `github_list_secrets` + confirm with the user first. Variables are PLAINTEXT config (readable back) — never put anything sensitive in one. Repo + environment scoping via `repo: "owner/repo"` + optional `environment`. `github_create_repo` creates a new repository (PRIVATE by default; needs the classic `repo` scope or fine-grained Administration:write on all repositories). Accounts are strictly PERSONAL. No Lynk tokens charged. | | `ssh` | `ssh_list_connections`, `ssh_run`, `ssh_upload`, `ssh_download`, `ssh_update_connection_config` — run shell commands + transfer files over SSH on servers the user registered as connections (and connections shared with them). One-shot execution — shell state does NOT persist across calls. Every command is audit-logged. Connections carry `instance_context` (host facts) + `instance_dont` (hard guardrails) — read both before acting. Connections the owner marked read-only reject obviously-destructive commands + anything off their allowlist. No Lynk tokens charged (the user brings their own servers). | | `brain` | `brain_search`, `brain_get_note`, `brain_list_notes`, `brain_create_note`, `brain_update_note`, `brain_delete_note`, `brain_graph`, `brain_list_tags` — the workspace **Brain**: a standalone Obsidian-style team knowledge base of interlinked markdown notes (`[[wikilinks]]`, backlinks, tags, FTS5 search, graph). Workspace-scoped (omit `workspace_id` for your personal workspace); any workspace member reads + writes. No tokens charged. | | `phone` | `phone_list_trunks`, `phone_call`, `phone_say`, `phone_get_call`, `phone_list_calls`, `phone_hangup` — **Lynk Phone**: place and drive REAL outbound phone calls over the workspace's own SIP carrier, speak on a live call (TTS), read the live transcript, hang up. Workspace-scoped via `workspace_members`. **`phone_call` rings an actual human and costs real money on the customer's carrier** — always confirm the destination number with the user first and never dial to "test" the tool. Two modes: a plain call you drive with `phone_say`, or a **managed agent** (pass `agent_system_prompt`) where Lynk runs the whole speech→LLM→speech loop autonomously. Billed in Lynk tokens per call minute / utterance / agent turn. | | `caya` | `caya_list_instances`, `caya_list_folders`, `caya_list_documents`, `caya_download_document`, `caya_move`, `caya_trash`, `caya_mark_read`, `caya_mark_unread`, `caya_tag`, `caya_create_folder`, `caya_rename_folder` — read + organise the user's connected **Caya** digital mailroom (scanned physical post). List folders (inbox/archive/trash + sub-folders) and documents, download a document PDF (base64, returned inline — never a public link, since Caya holds sensitive personal mail), and SORT mail: move/trash items, mark read/unread, tag, and create/rename folders. Connections are strictly PERSONAL — never shared with other Lynk users. No Lynk tokens charged (the user brings their own Caya account). | | `netbox` | `netbox_list_instances`, `netbox_search`, `netbox_query`, `netbox_get`, `netbox_schema`, `netbox_changelog`, `netbox_update_instance_config` — **READ-ONLY** access to connected **NetBox** instances (DCIM/IPAM source of truth): devices, racks, sites, interfaces, cables, IP addresses, prefixes, VLANs, VRFs, virtual machines, circuits, tenants, the object change log, and — where the netbox-inventory plugin is installed — hardware **assets, suppliers, purchases and deliveries**. Workspace-scoped via `workspace_members`. Cannot create, change or delete anything in NetBox. No Lynk tokens charged (the user brings their own NetBox token). | | `snipeit` | `snipeit_list_instances`, `snipeit_find_asset`, `snipeit_list`, `snipeit_get`, `snipeit_user_items`, `snipeit_activity`, `snipeit_update_instance_config` — **READ-ONLY** access to connected **Snipe-IT** asset registers: hardware (by asset tag, serial or text), everything checked out to a person, license seats, accessories, consumables, components, locations, models and the activity log. Workspace-scoped via `workspace_members`. Cannot check out, check in, create, change or delete anything. No Lynk tokens charged (the user brings their own Snipe-IT token). | | `grafana` | `grafana_list_instances`, `grafana_search_dashboards`, `grafana_get_dashboard`, `grafana_list_datasources`, `grafana_query`, `grafana_list_alerts`, `grafana_list_folders`, `grafana_update_instance_config` — **READ-ONLY** access to connected **Grafana** instances: dashboards **including the query each panel runs**, datasources, folders, metric queries (PromQL + SELECT-only SQL) and what is currently alerting. Workspace-scoped via `workspace_members`. Cannot create or change dashboards, alert rules or any other Grafana object. No Lynk tokens charged (the user brings their own Grafana service-account token). | | `elastic` | `elastic_list_instances`, `elastic_list_indices`, `elastic_field_caps`, `elastic_list_data_views`, `elastic_esql`, `elastic_search`, `elastic_get_document`, `elastic_list_saved_objects`, `elastic_list_alerts`, `elastic_cluster_health`, `elastic_update_instance_config` — **READ-ONLY** access to connected **Elasticsearch** clusters (+ optional Kibana): ES|QL and Query-DSL searches over the indices the connection is scoped to, index and data-stream discovery, field schemas, plus Kibana data views, saved objects and alerting rules. Workspace-scoped via `workspace_members`. Cannot index, update, delete or reindex anything, and system indices (anything starting with `.`) are unreachable — the security index holds API-key hashes. No Lynk tokens charged (the user brings their own Elasticsearch credential). | Scopes are requested at consent time. Granted scopes are baked into the access token and enforced server-side: tools whose scope is not granted are not registered on the server instance. ## Pricing — MCP costs the same as REST **An action costs the same whichever surface it arrives on.** `web_search` over MCP is billed exactly like `POST /search/v1/web`; `tech_lookup` over MCP is billed exactly like the REST lookup, per domain. The surface is recorded on the ledger entry, it does not change the price. This was not always true: nine lookup tools (`web_search`, `intel_lookup`, `domain_check`, `tech_*`, `scrape_*`) used to be free over MCP while the identical REST call was billed. They now charge, with the same **unit** as REST — so a `num=200` search costs 20 tokens on both, not 20 on one and 1 on the other. What that means for an agent: - The per-tool pricing notes below are authoritative for MCP too. Where a tool says "ask the user before raising `pages` / `num`", that guidance now has a real cost behind it. - Multi-unit calls scale: pay for what you ask for, get refunded what the upstream couldn't deliver (an early-exit search or a crawl that ran out of pages settles down automatically). - A failed call is refunded in full. - `estimate_only` / catalog tools (`domain_tlds`, `GET /tech/v1/tags`) remain free — they check nothing. - Unlimited-plan and admin accounts are never charged, on any surface. Insufficient balance surfaces as a tool error naming the cost and your balance, not a silent failure. Live prices per action are visible to the workspace admin at `https://api.lynk.run/admin/?tab=endpoints`. ## Tools reference ### `intel_lookup` Input: `{ target: string, mode?: 'lite' | 'medium' | 'full' }`. Default mode is `lite`. Modes: `lite` (local MMDB + DNS, ~10-50 ms) returns a **compact, consolidated** result — a single best `asn`/`org`/`country`/`country_name`/`city`/`region` merged across all providers, plus (domains only) `mail_provider` + `nameservers` + a `hosting`/`cloud_provider` flag. Small enough to fan out across hundreds of targets for bulk enrichment / market research. `medium` (+ rDNS/WHOIS/SSL, no DNSBL, ~200-800 ms) and `full` (everything incl. 73 IP RBLs + 8 domain RHSBLs, ~1-3 s) return the **full rich object** (per-provider blocks, DNS TXT, WHOIS, SSL). **In medium/full a `null` field means "not checked in this mode", NOT "checked and clean"** — use `full` before answering blacklist/reputation questions. ### `bgp_company_search` Input: `{ query: string, limit?: number, include_relationships?: ('upstreams'|'peers'|'customers')[] }`. Returns array of `{ asn, name, org, domain, country, prefixes[], prefix_count, total_ips, relationships: { upstreams, peers, customers } }`. Pass `include_relationships` to also get the actual ASN identities for the chosen types — each matched ASN then carries `relationship_names: { upstreams?[], peers?[], customers?[], truncated? }` (only the requested keys present) where each entry is `{ asn, name, org, domain, country }`. upstreams = transit providers ("who does this company buy transit from?"), customers = downstream networks, peers = lateral. Deduped, capped at 100 per type. Omit = counts only; narrow the types (e.g. just `["upstreams"]`) for large carriers with many downstreams. Free, no key needed in the underlying data sources (CAIDA + iptoasn). ### `domain_check` Input: `{ name?: string, tlds?: string[] | "all", domains?: string[], variations?: boolean, estimate_only?: boolean, async?: boolean, no_cache?: boolean }`. Pass `name` (a bare label, e.g. `mycoolstartup`) to fan out across `tlds`, and/or `domains` for explicit full domains. `tlds` defaults to `com, net, org, io, ai, app, dev, co, me, xyz, sh`; pass the literal string `"all"` for the full 40-ending catalog (see `domain_tlds`). Set `variations: true` to also fan the bare `name` across a curated set of startup affixes — prefixes `get, try, use, go, my, join, the, hey, just, with` + suffixes `app, hq, hub, ly, now, 24, labs, ai, io, ify, one, os, kit, flow` (so `acme` also yields `getacme`, `justacme`, `acmehq`, `acmeify`, … — **25 names per base**). Subdomains are reduced to the registrable domain (eTLD+1). Max 5000 domains per call. **Size and runtime — read this before a big sweep.** The candidate count multiplies: `variations` alone is 25 names, and 25 × the full TLD catalog is **1000 domains**. Runtime is driven by how many endings answer slowly, not by the domain count — a cached or free name resolves in milliseconds, while a registry that blocks us costs seconds and returns `unknown`. Measured against production: 50 cold domains take ~2s when most are free, ~22s when many are blocked. - `estimate_only: true` returns `{ candidate_count, estimated_seconds: { fast, typical, slow }, runs_async, exceeds_limit }` and **checks nothing** — free, no tokens. Use it to tell the user what a sweep will cost before running it. - **Above 200 candidates the tool does not block.** It returns `{ mode: "job", job_id, total, estimated_seconds, … }` immediately; poll `domain_check_status` until `status` is `"completed"`. `async: true` forces that path below the threshold. Below it you get `{ mode: "sync", summary, results }` as before. - **Ask the user before launching a sweep above 200 candidates — unless they already asked for a broad search** ("check all TLDs", "every variation", "full sweep"). If they did, just run it. Returns (sync mode) `{ mode: "sync", summary: { total, available, taken, unknown, for_sale, parked }, invalid[], results[] }`; each result is `{ domain, tld, status, listing, price, buy_url?, method, reason, checked_at }`, where `method` is one of `dns | rdap | whois | whoapi | none`. Tiered cascade per domain: **DNS** (NS records ⇒ `taken`, ~tens of ms) → **RDAP** (HTTP 404 ⇒ `available`, authoritative) → **port-43 WHOIS** pattern match for TLDs without RDAP → optional **WhoAPI** third-party fallback (only when the admin has configured a key, and only when tiers 1–3 all returned `unknown` — covers registries that block our IP, e.g. `.es`, or rate-limit us heavily, e.g. `.uk`, `.shop`). **`status: 'unknown'` means no source could decide (registry blocked us, exotic TLD) — it does NOT mean available.** **Aftermarket:** on a `taken` domain, `listing` refines the verdict — `'for_sale'` (listed on a domain marketplace / sales lander detected), `'parked'` (a parking page was detected — often, but not provably, for sale), or `null` (regular website / no signal). A `for_sale` result may carry `price` (`{ amount, currency }`) — the buy-now asking price where the marketplace publishes one; `price: null` on a for_sale listing means "Price on Request" — plus `buy_url`, the purchase/click target. Nameserver-based for_sale/parked detection runs for every domain; HTTP-based enrichment (lander visit + price extraction) runs for the first 50 taken domains per call. `taken` results (incl. their listing/price) are cached 24 h, `available` results 10 min. ### `domain_check_status` Input: `{ job_id: string, include_results?: boolean }`. Polls a background sweep started by `domain_check` in `mode: "job"`. Returns `{ job_id, status, total, completed, progress_pct, estimated_seconds_remaining?, summary?, invalid?, error? }`. `status` is `running | completed | failed`. Pass `include_results: true` on the FINAL poll to also get every per-domain record — omit it on progress ticks, since a 1000-domain sweep carries that many records. While running, wait roughly `estimated_seconds_remaining` between polls instead of hammering. Jobs (and their results) are kept for **24 hours**. ### `domain_tlds` No input. Returns `{ default[], all[], count, note }` — the curated default endings used when `tlds` is omitted, and the full catalog that `tlds: "all"` expands to. Call it when the user asks for "all TLDs" or names an ending you're not sure is covered, instead of hardcoding a list. Free, no tokens. ### `web_search` Input: `{ q: string, gl?: string, hl?: string, num?: number, format?: 'json' | 'markdown' }`. Runs a Google web search via serper.dev and returns organic results plus answer box / knowledge graph / "people also ask" / related searches when present. **Pricing — read carefully:** `1 token per 10 results requested`, rounded up. `num=10` (default) costs 1 token; `num=30` costs 3 tokens; `num=100` costs 10; `num=200` (max) costs up to 20 tokens. The gateway handles Google's 10-per-page reality internally by fetching multiple serper pages and stitching them — that's why num scales linearly with cost. Early-exit on sparse queries refunds unused pages automatically (Google empty after page 3 of a num=50 request → user pays 3, not 5). **⚠️ AGENT INSTRUCTIONS:** Do NOT silently escalate `num` above the default of 10. The default answers most research questions for 1 token. Before requesting more than 10 results, **ask the user first** whether they want to spend the extra tokens. Only escalate when the top 10 were insufficient AND the user explicitly approved. Defaults: `gl=de`, `hl=de`, `num=10`. Max `num=200`. Pass `format='markdown'` to get an LLM-friendly rendered document instead of JSON — recommended when feeding the result straight back into an LLM context window. Authenticated calls bypass per-minute rate limits; unauth callers are limited to 20 / min / IP. JSON shape: `{ query, gl, hl, num, requested, fetched_at, organic[], answer_box?, knowledge_graph?, people_also_ask?, related_searches? }` — `num` is what we actually returned (may be lower than `requested` if Google ran out). **Optional sections (`answer_box`, `knowledge_graph`, `people_also_ask`, `related_searches`) are absent when Google doesn't return them — don't assume they exist.** ### `scrape_page` Input: `{ url: string, modes?: Array<'markdown' | 'html' | 'screenshot' | 'pdf' | 'links' | 'images' | 'branding'>, ...options }`. Pass one or more modes to capture several formats in one call; default is `['markdown']` (best for LLM context). The `images` mode returns `[{ src, alt?, width?, height? }]`. The `branding` mode runs a browser-side evaluator (borrowed from dembrandt, MIT) inside the existing Crawl4AI Playwright session and returns a Brand-DNA envelope: `{ siteName, colorScheme, logo: { url, type:'wordmark'|'logomark'|'combination', source:'img'|'svg'|'css-background', reversed, background, width, height, alt }, logoInstances[], favicons[] (incl. PWA manifest icons + og:image + twitter:image + apple-touch-icon), colors: { primary, secondary, accent, background, textPrimary, textSecondary }, palette[{ normalized, count, confidence, sources }], cssVariables, typography: { fontFamilies, fontSizes, fontWeights, sources: { googleFonts[], adobeFonts, variableFonts[] }, styles[] } }`. Cached 1 h per `(url, modes, options)`. Authenticated calls bypass per-minute rate limit and bill via tokens. **Optional Firecrawl-style options block** (omit for defaults; same shape on `scrape_crawl_start`): - `excluded_tags: string[]` — HTML tags to strip (e.g. `['nav','footer','aside']`). - `include_selector: string` — CSS selector that scopes extraction to a single element (e.g. `'main article'`). - `wait_ms: number` — Delay after page-load before reading HTML, for JS-hydrated sites. Cap 30000. - `timeout_ms: number` — Hard page-load timeout. Cap 120000. - `main_content_only: boolean` — Drop nav/footer/aside/header/forms + short blocks (readability filter). - `screenshot_full_page: boolean` — Full-page screenshot instead of viewport-only. Only meaningful with the `screenshot` mode. - `max_age_ms: number` — Per-request cache TTL override. Pass `0` to force a fresh scrape; otherwise the smaller of this value and the global 1 h default wins. - `html_raw: boolean` — Treat the raw `html` field as primary instead of `cleaned_html` (both are always present in the response). - `parse_pdf: boolean` — When the URL points at a PDF, parse it via Crawl4AI's PDF strategy instead of rendering in the browser. Best-effort — falls back with a 502 if the sidecar build doesn't ship the strategy. - `enhanced_mode: boolean` — Anti-bot tarning (Crawl4AI `magic`: simulate_user + override_navigator + random UA). Slower per page, helps with light bot-protection. NOT a captcha solver. ### `scrape_crawl_start` + `scrape_job_status` Multi-page crawls run asynchronously. `scrape_crawl_start` returns a `job_id` immediately; poll with `scrape_job_status({ job_id })` until `status === 'completed'`. Strategies: `bfs` (default), `dfs`, `best_first`. `modes` accepts the same multi-select array as `scrape_page` (including `images`). The same options block as `scrape_page` is accepted and applied to every page in the crawl. Caps: max 250 pages, max depth 4. ### `scrape_extract` Input: `{ url: string, prompt: string }`. The Lynk gateway uses an admin-configured LLM provider to extract structured data; returns 503 (`Upstream scraper unavailable` or similar) if no provider is configured. ### `files_list` / `files_delete` / `files_upload` List, delete and upload files via Lynk Files. `files_list` / `files_delete`: admins see all files, regular users see only their own. `files_upload({ file_name, content_base64? | source_url?, password?, expires_in_days? })` stores a file and returns `{ id, url, size, expires_at, has_password }` — `url` is the public `https://lynk.run/dl/` download link (gated by `password` if set, auto-expiring after `expires_in_days` 1-365 if set, else evergreen). Provide the bytes ONE of THREE ways: (1) `source_url` (a public http(s) URL Lynk fetches server-side, SSRF-guarded, no redirects followed, ≤250 MB); (2) `content_base64` (inline, ≤15 MB); or (3) NEITHER — call with just `file_name` (+ optional `password`/`expires_in_days`) and the tool returns `{ mode:"upload_url", upload_url, method:"PUT", field:"file", expires_at, instructions }`. The `upload_url` is a short-lived (~30 min) presigned link; the user (or any process) PUTs the raw bytes to it with NO auth header (`curl -X PUT -F "file=@local.apk" ""`) — the URL is the credential. Mode (3) is the path for a LARGE local file that has no public URL and is too big to base64 inline (the bytes never transit the LLM tool call). Per-user shared-file storage quota: 500 MB total (buckets excluded; over quota → HTTP 413). No tokens charged. The tool does NOT email the link — share the returned `url` yourself. ### `bucket_*` — per-user object store A bucket is a private, owner-only named container of objects addressed by `(bucket, key)`. Unlike shared files there is no public link — it is an authenticated object store giving a STABLE address so a CI run (or any bulk transfer) can upload once and fetch the same key deterministically on every later run, with no opaque file id to persist. Caps: 50 buckets/user, 1000 objects/bucket, 10 MB/object. - `bucket_list()` → `[{ id, name, created_at }]`. - `bucket_create({ name })` → `{ id, name, created_at, created }`. Idempotent — returns the existing bucket if present (safe to call at the top of every run). Names: lowercase a-z, 0-9, hyphens, 1-64 chars. - `bucket_delete({ name })` → `{ ok, name }`. Deletes the bucket AND all its objects. Irreversible. - `bucket_list_objects({ bucket, prefix? })` → `[{ key, size, mime_type, created_at }]`. Optional key-prefix filter (e.g. `prefix: "certs/"`). - `bucket_put_object({ bucket, key, content_base64? | source_url? })` → `{ ok, key, size }`. UPSERT-by-key (re-uploading a key overwrites it). Keys are slash-separated path segments (letters, digits, `. _ - + @ ( )` per segment), e.g. `"certs/distribution.p12"`. Same base64-or-source_url choice as `files_upload`; ≤10 MB/object. - `bucket_get_object({ bucket, key })` → `{ key, size, mime_type, content_base64 }`. Bytes are base64-encoded in the response. - `bucket_delete_object({ bucket, key })` → `{ ok, key }`. ### `salesforce_list_instances` Lists the Salesforce orgs the user has connected. Returns `{ id, name, login_url, api_version, last_test_ok, instance_context, instance_dont, writes: { flows, records, fields, layouts, metadata, apex }, ... }`. `writes` shows which WRITE features the connection owner enabled in the dashboard "Write Access" tab — a disabled feature's write tools refuse until it's switched on (flows defaults on; records/fields/layouts/metadata/apex default off). Every other `salesforce_*` tool needs an `instance_id` from this list. ### `salesforce_soql` + `salesforce_soql_more` Input: `{ instance_id: number, soql: string }`. Runs a **read-only** SOQL `SELECT` (no DML — non-SELECT input is rejected). A default `LIMIT` is appended when the query has none and is not an aggregate. Returns `{ totalSize, done, records, nextRecordsUrl? }`. When `done` is false, call `salesforce_soql_more({ instance_id, next_records_url })` with the locator to page through results. ### `salesforce_tooling_soql` Input: `{ instance_id: number, soql: string }`. Runs a **read-only** SOQL `SELECT` against the Salesforce **Tooling API** — the metadata objects the normal Data API (and therefore `salesforce_soql`) cannot see: `ValidationRule`, `ApexClass`, `ApexTrigger`, `WorkflowRule`, `EntityDefinition`, `FieldDefinition`, `CustomField`, `DuplicateRule`, `Layout`, `Flow`, … Returns the same `{ totalSize, done, records, nextRecordsUrl? }` shape. **When to reach for it:** `salesforce_soql` failing with `sObject type 'X' is not supported` means X is a Tooling object, NOT that it is missing — re-run the identical query here. **Metadata is one record at a time.** The `Metadata` and `FullName` fields carry the real definition (a validation rule's `errorConditionFormula`, an Apex class's `Body`, …) and Salesforce only allows them when the query matches a SINGLE row. So it's a two-step read: 1. `SELECT Id, ValidationName, Active, Description, ErrorMessage, ErrorDisplayField FROM ValidationRule WHERE EntityDefinition.QualifiedApiName = 'Contract'` 2. per rule: `SELECT Id, FullName, Metadata FROM ValidationRule WHERE Id = '03d…'` Selecting `Metadata` over a multi-row filter fails with an implementation-restriction error. Same rule for `ApexClass.Metadata`, `Flow.Metadata`, `Layout.Metadata`. Also handy: `SELECT QualifiedApiName, DataType, IsCalculated FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = 'Contract'` — `IsCalculated` tells you reliably whether a field is a formula (a describe-based guess is not authoritative). ### `salesforce_search` Input: `{ instance_id: number, sosl: string }`. Runs a read-only SOSL `FIND` text search across objects, e.g. `FIND {Acme} IN ALL FIELDS RETURNING Account(Id, Name)`. Returns `{ searchRecords }`. ### `salesforce_describe_global` + `salesforce_describe` `salesforce_describe_global({ instance_id })` lists every sObject (`name`, `label`, `custom`, `queryable`, `keyPrefix`). `salesforce_describe({ instance_id, sobject })` returns one object's fields (name, label, type, picklist values, references) — use it to write correct SOQL. ### `salesforce_get_record` Input: `{ instance_id, sobject, record_id, fields? }`. Reads one record by 15/18-char id. Read-only. ### `salesforce_slippage_report` Input: `{ instance_id, period: 'month' | 'quarter', anchor: string }`. Computes the slippage report for a given calendar month or quarter. `anchor` format: `'YYYY-MM'` for `period='month'` (e.g. `'2026-05'`), `'YYYY-Q[1-4]'` for `period='quarter'` (e.g. `'2026-Q2'`). A slipped opportunity is one whose `CloseDate` at the START of the period was inside the period but is now scheduled AFTER the period end, and is not Closed-Won. Reconstructed live from `OpportunityFieldHistory` — **requires "Set History Tracking" enabled on `Opportunity.CloseDate` in the Salesforce org**, otherwise the report returns a warning and zero slips. Returns `{ report: { period, outcome, slip_rate_pct, slip_value_rate_pct, chronic_slipper_count, aging_buckets, slipped: [...] , warnings }, dataset: { opp_count, history_count, refreshed_at, truncated } }`. Each slipped row carries the full `close_date_history` (every CloseDate change with `at`, `from`, `to`) so an agent can narrate why a deal slipped. Per-instance dataset cached 15 min; switching periods is instant. ### `salesforce_list_files` Input: `{ instance_id, record_id?, limit? }`. Lists files in the org — both modern Salesforce Files (`ContentDocument`/`ContentVersion`) and legacy `Attachment`s. With `record_id` (15/18-char), returns files linked to that record (both models); without it, the most recently created Files. Each row: `{ download_id, model: 'file' | 'attachment', content_document_id, title, file_extension, content_type, size, created_date }`. Pass `download_id` to `salesforce_download_file`. Read-only. ### `salesforce_download_file` Input: `{ instance_id, file_id }`. Downloads one file and returns it as base64: `{ download_id, file_name, content_type, size, content_base64 }`. `file_id` accepts a `ContentVersion` id (`068…`), a `ContentDocument` id (`069…` — auto-resolved to its latest version), a legacy `Attachment` id (`00P…`) or a `Document` id (`015…`) — typically the `download_id` from `salesforce_list_files`. Files larger than the 25 MB cap are refused. Read-only — no org-side setup required (unlike NetSuite, Salesforce's REST API serves blob bodies natively). ### `salesforce_list_flows` Input: `{ instance_id, limit? }`. Lists the org's Flows via the Tooling API — one entry per flow. Each: `{ definition_id (300…), developer_name, description, active_version_id (301…|null), latest_version_id (301…|null), active_version, latest_version }` where the version objects carry `{ Id, MasterLabel, VersionNumber, Status, ProcessType }`. Pass an `active_version_id`/`latest_version_id` to `salesforce_get_flow`, or the `definition_id` to `salesforce_activate_flow`. Read-only. ### `salesforce_get_flow` Input: `{ instance_id, flow_id }`. Returns one flow version's full definition, including the editable `Metadata` object + `FullName`. `flow_id` is a Flow version id (`301…`) or a FlowDefinition id (`300…`, resolved to its latest version). The `Metadata` object is what you edit and pass back to `salesforce_save_flow`. Can be large. Read-only. ### `salesforce_save_flow` Input: `{ instance_id, full_name, metadata }`. **OWNER-ONLY write.** Creates a NEW version of the flow named `full_name` from the full flow `metadata` object (get the current one via `salesforce_get_flow`, edit, pass back). Salesforce does NOT allow editing an active version in place — this ALWAYS creates a new, **INACTIVE** (Draft) version and leaves the live flow untouched until you call `salesforce_activate_flow`. Under the hood the connector deploys the `metadata` via the Salesforce **Metadata API** (a zip deploy — the Tooling API can't add a version to an existing flow) and forces the new version to Draft, so you can pass the `Metadata` from `salesforce_get_flow` back unchanged (or edited) and always get an inactive version; you do NOT need to touch `status`/`fullName` yourself. **For an EDIT to an existing flow prefer `salesforce_patch_flow`** — this tool needs the whole 10–15 KB definition and nothing catches a transcription slip. Pass `validate_only: true` to VALIDATE the metadata without saving anything: without it, an attempt that only proved the metadata malformed still leaves a throwaway Draft, overwriting the existing one. The deploy is async: the tool waits ~45s and then returns `{ status: "pending", request_id }` rather than hanging past the transport timeout — that is NOT a failure, poll `salesforce_deploy_status` and do NOT re-run the deploy. Returns `{ id (deployed Flow component id), success, status, request_id, errors }`. Requires the run-as user to have "Manage Flow" + "Modify Metadata Through Metadata API Functions" in Salesforce. SOQL permission check: on `PermissionSet`/`Profile` the columns are `PermissionsManageInteraction` (= Manage Flow — `PermissionsManageFlow` does not exist) + `PermissionsModifyMetadata`, joined through `PermissionSetAssignment`. Confirm with the user first; recommend reviewing the new version in Flow Builder before activating. ### `salesforce_activate_flow` Input: `{ instance_id, flow_definition_id, version_number }`. **OWNER-ONLY write (`writes.flows` toggle).** Sets which version of a flow is active in the live org; `version_number: 0` deactivates all versions. Changes PRODUCTION automation — confirm the exact flow + version first. Requires the run-as user to have "Manage Flow". Use `salesforce_list_flows` for the `flow_definition_id` (300…) and version numbers. ### `salesforce_create_record` / `salesforce_update_record` / `salesforce_delete_record` **OWNER-ONLY writes, gated by the connection's `writes.records` toggle.** Plain REST record DML — one record per call. `salesforce_create_record({ instance_id, sobject, fields })` → `{ id, success }`; `salesforce_update_record({ instance_id, sobject, record_id, fields })` changes only the passed fields (`Id`/`attributes` keys are stripped so queried records round-trip); `salesforce_delete_record({ instance_id, sobject, record_id })` sends the record to the Salesforce recycle bin (destructive — confirm the exact record with the user first; parent deletes may cascade to children). Use `salesforce_describe` for correct field API names/types; lookup fields take the related record's 15/18-char id. Writes run as the connection's run-as user against PRODUCTION data — `INSUFFICIENT_ACCESS` means that user lacks object/field permission. ### `salesforce_save_field` Input: `{ instance_id, sobject, field_name, metadata, validate_only? }`. **OWNER-ONLY write (`writes.fields` toggle).** Creates OR updates a CUSTOM field (`field_name` must end in `__c`) on a standard or custom object — one Metadata API deploy with upsert semantics. `metadata` is the CustomField definition without fullName, e.g. `{ "label": "Deal Score", "type": "Number", "precision": 3, "scale": 0 }`; Text needs `length`, Picklist needs `valueSet.valueSetDefinition.value[]`, Checkbox needs `defaultValue`, Lookup needs `referenceTo` + `relationshipName`. `validate_only: true` runs a validation deploy (checks everything, saves nothing). **CRITICAL: a freshly created field is invisible to every user until FLS is granted — call `salesforce_set_field_permissions` right after.** Requires "Customize Application" + "Modify Metadata Through Metadata API Functions" on the run-as user. ### `salesforce_create_object` Input: `{ instance_id, full_name, metadata, validate_only? }`. **OWNER-ONLY write (`writes.fields` toggle).** Creates a custom object (`full_name` ends in `__c`; updates are additive — never delete existing fields). Minimal metadata for a new object: `{ "label", "pluralLabel", "nameField": { "label", "type": "Text" }, "deploymentStatus": "Deployed", "sharingModel": "ReadWrite" }`. Add fields afterwards with `salesforce_save_field`. The object needs a tab + permissions in Setup before end users see it. ### `salesforce_set_field_permissions` Input: `{ instance_id, permission_set_id, sobject, field_name, read?, edit? }`. **OWNER-ONLY write (`writes.fields` toggle).** Grants or updates field-level security for one field on one permission set (query-first, create-or-update — safe to re-run). Defaults to read+edit. Find permission sets via SOQL: `SELECT Id, Label FROM PermissionSet WHERE IsOwnedByProfile = false`. The required follow-up after `salesforce_save_field`. ### `salesforce_list_applications` / `salesforce_get_application` `salesforce_list_applications({ instance_id, limit? })` lists the org's Lightning/Classic apps (Tooling API). Each row: `{ id, full_name, developer_name, label, namespace_prefix, deployable }`. **Use `full_name`** — it is what a deploy addresses the app by and is NOT the DeveloperName: an app with a namespace differs (`"standard__EasyMarketing"` has DeveloperName `"EasyMarketing"`), while a local app like `"Link11_Sales"` is the same either way. `deployable: false` marks an app from a MANAGED package, whose metadata cannot be changed. `salesforce_get_application({ instance_id, application })` takes the `02u…` id or the DeveloperName and returns the app's editable `Metadata` (`tabs` = the ordered nav, plus `label`, `brand`, `navType`/`uiType`, `formFactors`, `utilityBar`, `actionOverrides`, `profileActionOverrides`) together with its `FullName`. Read-only, not toggle-gated — but it is the REQUIRED first step before `salesforce_deploy_metadata` touches an app. ### `salesforce_list_layouts` / `salesforce_get_layout` `salesforce_list_layouts({ instance_id, sobject?, limit? })` lists classic page layouts (Tooling API; `sobject` filters to one object — custom objects are resolved automatically). `salesforce_get_layout({ instance_id, layout_id })` returns one layout's editable `Metadata` + `FullName` (e.g. `"Account-Account Layout"`). Read-only, not toggle-gated. ### `salesforce_save_layout` Input: `{ instance_id, full_name, metadata, validate_only? }`. **OWNER-ONLY write (`writes.layouts` toggle).** Deploys an edited classic page layout. **NO draft state — the deploy replaces the whole layout and is live immediately** for its assigned profiles. Always fetch the current definition via `salesforce_get_layout`, edit it, and prefer `validate_only: true` as a dry run. `full_name` must be passed back exactly as returned (layout names contain spaces / URL escapes). ### `salesforce_patch_layout` Input: `{ instance_id, layout_id, ops: [{ op, field?, section?, column?, after?, before?, behavior?, action?, action_type?, position? }], validate_only? }`. **OWNER-ONLY write (`writes.layouts` toggle).** Surgical edit of a classic page layout. Ops: `add_field` / `remove_field` / `move_field` / `set_behavior` (`Edit` | `Readonly` | `Required`) and `add_action` / `remove_action` (the "Salesforce Mobile and Lightning Experience Actions" bar; `action_type` QuickAction | StandardButton | CustomButton | ProductivityAction, optional 0-based `position`, sortOrder renumbered). Place fields with `after`/`before` another field or `section` label + 1-based `column` (appends). Field names are object-relative (`Region__c`). Server-side fetch → patch → verify that sections, columns, related lists, blank spaces, every other field + its behavior and the action set are unchanged apart from the addressed items → deploy via the same path as `salesforce_save_layout`. Any drift → refuses, deploys nothing. Addressing mistakes (duplicate add, missing field, unknown section/column, custom-links section) are errors. Live immediately; dry-run with `validate_only: true`. ### `salesforce_list_flexipages` / `salesforce_get_flexipage` `salesforce_list_flexipages({ instance_id, limit? })` lists Lightning pages (FlexiPages — App Builder record/app/home pages): `{ id, developer_name, master_label, entity_definition_id, type }`. `salesforce_get_flexipage({ instance_id, flexipage_id })` returns one page's editable `Metadata` + `FullName`. Read-only, not toggle-gated. ### `salesforce_save_flexipage` Input: `{ instance_id, developer_name, metadata, validate_only? }`. **OWNER-ONLY write (`writes.layouts` toggle).** Deploys a Lightning page — updates an existing page in place (live immediately, no draft state) or creates a new one (upsert by developer name). A brand-NEW page is deployed but NOT visible until it's activated/assigned in the Lightning App Builder (org default / app / record type assignments live in other metadata and are deliberately out of scope). FlexiPage metadata is the most complex layout format — component errors surface from the deploy verbatim. ### `salesforce_patch_flexipage` Input: `{ instance_id, developer_name, patches?: [{ component_identifier, property, op?, value?, value_list? }], field_ops?: [{ op: add|remove|move|set_behavior, field, after?, before?, section?, column?, ui_behavior?, identifier? }], structure_ops?: [{ op: add_section|add_tab|move|rename|set_visibility|remove, target?, label?, columns?, tab?, after?, before?, visibility? }], validate_only?, test_level?, run_tests? }` (at least one patch, field op or structure op; max 100 in total). **OWNER-ONLY write (`writes.layouts` toggle).** Changes INDIVIDUAL component properties on a Lightning page without resending the page. **Prefer this over `salesforce_save_flexipage` for every edit to an existing page**: that tool replaces the whole definition, real record pages run to hundreds of thousands of characters, and a transcription slip silently deletes components — which `validate_only` does NOT catch, because a page missing a component is still structurally valid. This keeps the round-trip server-side: fetch → patch in memory → verify component/field/region counts, the identifier set and every unaddressed component's property fingerprint are unchanged → deploy. If anything else drifted it refuses and deploys nothing. Get `component_identifier` (e.g. `lst_dynamicRelatedList21`) and the property name from `salesforce_get_flexipage`. A property carries EITHER a scalar (`value`) OR a list (`value_list` → `valueList.valueListItems`) — pass exactly one. `op: "remove"` deletes the property. An unknown identifier, or removing a property that is not set, is an ERROR, never a silent no-op. **`field_ops`** edit Dynamic Forms fields: add / remove / move a field (`field` = `Foo__c` or `Record.Foo__c`) placed `after`/`before` another field or at the end of `section` (field-section label or identifier) + 1-based `column`, or change its `ui_behavior` (`none` | `readonly` | `required`). The verification then also requires the page's field set to equal before − removed + added. A field that sits on the page twice needs `identifier` (the error lists the instances). **`structure_ops`** edit sections, tabs and show/hide rules: `add_section` { `label`, `columns` 1|2, `after`/`before` a sibling or `tab` }, `add_tab` { `label`, `after`/`before` another tab }, `move` { `target`, `after`/`before`/`tab` } (reorder sections, move a section to another tab, reorder tabs), `rename` { `target`, `label` }, `set_visibility` { `target` (section, tab, component or field), `visibility`: { `criteria`: [{ `left_value` ("Record.Status", "$User.Profile.Name"), `operator` EQUAL|NE|GT|GE|LT|LE|CONTAINS, `right_value` }], `boolean_filter`? } or `null` to always show }, `remove` { `target` } (an EMPTY section or non-default tab only). Targets are a section label, tab title ("Details" matches "ℹ️Details") or identifier. Non-`remove` structure ops run before `field_ops`, `remove` after — so one call can add a section, move fields into it and delete the section that was emptied. The verification additionally proves no component changed its container or visibility rule unless an op addressed it. Pages that show `force:detailPanel` render the classic layout — use `salesforce_patch_layout` there. Returns the before/after of every changed property, `field_changes` (where each field landed), `structure_changes` plus the verified structure. Live immediately (no draft state). ### `salesforce_patch_flow` — change one thing in a flow without resending it `salesforce_save_flow` needs the **whole** definition, and a real flow is 10–15 KB of JSON. Transcribing all of it to change one filter is how a nightly flow that writes Account fields org-wide quietly starts doing something else: the deploy succeeds, `status: Draft` looks right, and a flow missing a filter is still a valid flow. **Prefer `salesforce_patch_flow` for every edit to an existing flow.** ``` salesforce_patch_flow({ instance_id: 1, full_name: "Contract_Replace", patches: [{ element: "Get_Open_Contracts", // the element's `name` (unique across the flow) property: "filters", op: "append", // set | append | remove value: { field: "Status", operator: "NotEqualTo", value: { stringValue: "Draft" } } }], validate_only: true // dry run — creates no Draft version at all }) ``` The round-trip stays on the server: it fetches the version, patches in memory, and **verifies that nothing except the addressed properties changed** — element count, **connector count** (the flow's wiring, which nothing else checks), the set of element names, and every other element's property fingerprint. Any other drift and it refuses, deploying nothing. - `element` is the element's `name`. Nested named elements work too (a decision's `rules`, a screen's `fields`), and the start element — where entry criteria live — is addressed as **`"start"`**. - Ops: `set` (replace or create), `append` (push ONE item onto a list, creating the list if absent), `remove` (delete the property). Removing one item *from* a list is not an op — read it, then `set` the list you want. - Patching `name` is refused: renaming an element means rewriting every connector that references it. - A missing element or property is an **error listing the real names**, never a silent no-op. - Like `salesforce_save_flow` it always creates a new **inactive (Draft)** version; the live flow is untouched until `salesforce_activate_flow`. - `from_version` (`latest` default, or `active`) picks which version the patch is based on — they differ whenever a newer Draft exists. The one used comes back as `source_version_number`. ### Deploys are async — `status: "pending"` and `salesforce_deploy_status` Every Metadata API deploy tool (`salesforce_deploy_metadata`, `salesforce_save_flow`, `salesforce_patch_flow`, `salesforce_save_field`, `salesforce_create_object`, `salesforce_save_layout`, `salesforce_save_flexipage`, `salesforce_patch_flexipage`) returns a **`request_id`**, and returns **`status: "pending"`** when the deploy was still running after ~45s. `pending` is **not a failure**. The package is posted and Salesforce is finishing it; the tool stops waiting so the call returns inside the MCP transport's own timeout rather than dying at ~60s with nothing to show for it. Poll: ``` salesforce_deploy_status({ instance_id: 1, request_id: "0AfaV0000019IunSAE" }) → { request_id, status: "pending" | "completed", done, validate_only, components: [{ type, full_name, id, created, changed }] } ``` A deploy that **failed** is reported by this tool as an error carrying the same component failures, Apex test failures and coverage warnings the deploy call would have raised — one failure format wherever the failure landed. **Do not guess the outcome, and do not re-run the deploy.** Inferring it from `FlowVersionView.Status` or a `LastModifiedDate` is unreliable, and a blind retry is worse: the first deploy is still in flight, so repeating it applies the change twice (two flow versions, or two deploys racing on one component). ### `salesforce_deploy_metadata` Input: `{ instance_id, components: [{ type, full_name, metadata?, files? }], validate_only?, test_level?, run_tests? }`. **OWNER-ONLY write.** Config types need the `writes.metadata` toggle; `ApexClass`/`ApexTrigger` need the SEPARATE `writes.apex` toggle, and a package mixing both needs both. Both are their own switch, default OFF. The generic Metadata API deploy: the one path for org-configuration types that have no dedicated tool, plus Apex. UPSERT — the same call creates a component that does not exist and updates one that does, so there is no create/update split. Supported `type` values: `ValidationRule`, `RecordType`, `CompactLayout`, `WebLink`, `ListView`, `FieldSet`, `BusinessProcess` (object-scoped — `full_name` must be `"."`, e.g. `"Account.Require_Region"`); `QuickAction` (`"."` or a bare global action), `CustomPermission`, `GlobalValueSet`, `CustomTab`, `CustomMetadata` (a custom-metadata RECORD — `"__mdt."`), `StandardValueSet` (the standard picklists — `full_name` is the value-set name ALONE, e.g. `"TaskType"`, never `"Task.Type"`), `CustomApplication` (an APP definition — this is how an app's navigation, its `tabs` list, is changed), `PathAssistant` (a Path / "Guidance for Success" — `full_name` is the path's own API name ALONE, e.g. `"Link11_PoC_Contract_Path"`, never `"Contract.…"`). **`CustomMetadata`** takes `values` as an ARRAY of `{field, value}` (never a map), e.g. `{ "label": "Contact Last Call", "protected": false, "values": [ {"field": "dlrs__ParentObject__c", "value": "Contact"}, {"field": "dlrs__Active__c", "value": true} ] }`. Each value's XML type is derived from the JSON — string / boolean / number → `double` / `"YYYY-MM-DD"` → `date` / ISO timestamp → `dateTime`; `null` CLEARS the field. Add `"type"` to a value to pin it (`string`, `boolean`, `double`, `int`, `long`, `decimal`, `date`, `dateTime`, `time`; `"xsd:string"` also accepted) — the case that needs it is a TEXT field whose content merely looks like a date. `label` is required; `protected` defaults to `false`. Plus the **source** types, which deploy FILES rather than one XML document — the two **bundle** types `LightningComponentBundle` (an LWC) and `AuraDefinitionBundle` (an Aura component/app/event/interface), which are a FOLDER of files, and the two **Apex** types `ApexClass` and `ApexTrigger`, which are a single source file. `full_name` is the component's own name alone in all four cases. **Bundle types take `files`, not `metadata`.** `files` maps each file name — relative to the bundle folder, **flat, no `/`** — to its text; `metadata` is optional there and only builds the `*-meta.xml` sidecar when `files` does not contain one. ```json { "type": "LightningComponentBundle", "full_name": "orgChart", "metadata": { "apiVersion": "62.0", "isExposed": true, "masterLabel": "Org Chart", "targets": { "target": ["lightning__RecordPage"] } }, "files": { "orgChart.html": "", "orgChart.js": "import { LightningElement } from 'lwc';\nexport default class OrgChart extends LightningElement {}", "orgChart.css": ".x { color: red; }" } } ``` An LWC needs `".js"`; `".html"` is optional (a service component has none) and the bundle name is camelCase starting lowercase. The `".js-meta.xml"` sidecar is **required** — supply it in `files` when you need `targetConfigs` (it uses XML *attributes*, which the generated sidecar cannot express), otherwise pass `metadata` `{ apiVersion, isExposed, masterLabel?, description?, targets? }` and it is generated; `isExposed` defaults to `false` (not exposed anywhere) when omitted. An Aura bundle needs exactly one primary file (`.cmp` / `.app` / `.evt` / `.intf` / `.tokens`); its sidecar name is derived from that (`MyCmp.cmp` → `MyCmp.cmp-meta.xml`). **A bundle deploy REPLACES the whole bundle — a file you leave out is DELETED from it.** When editing an existing component, read it first: `SELECT Id, FilePath, Source FROM LightningComponentResource WHERE LightningComponentBundle.DeveloperName = 'orgChart'` (`salesforce_tooling_soql`). Binary files are not supported (`files` values are text), and each file is capped at 512 KB with 2 MB per deploy. ### Apex (`ApexClass` / `ApexTrigger`) — `writes.apex` Apex is CODE, so it sits behind its own toggle. It takes `files` like a bundle, but it is a single source file and **its `-meta.xml` sidecar is generated for you** (`apiVersion` = the connection's API version, `status` `Active`), so you normally pass nothing else: ```json { "type": "ApexClass", "full_name": "ActivityRelatedContactsController", "files": { "ActivityRelatedContactsController.cls": "public with sharing class ActivityRelatedContactsController {\n @AuraEnabled(cacheable=true)\n public static List getContacts(Id recordId) { return null; }\n}" } } ``` `full_name` is the class/trigger name alone, and **the name the source declares must match it** — a mismatch is refused before anything is deployed. A trigger names its own object in the source (`trigger AccountTrigger on Account (before insert) { … }`); `full_name` is still just `"AccountTrigger"`. Pass `metadata` only to override the sidecar: `{ "status": "Inactive" }` deploys a trigger switched off (`ApexClass` has no such option — MDAPI's `status: "Deleted"` is not a delete and is refused). **Tests are mandatory for Apex: `test_level` is REQUIRED whenever the package contains Apex.** A production org runs Apex tests on every deploy — a `validate_only` one included — and needs 75% coverage across all Apex. Use `test_level: "RunSpecifiedTests"` with `run_tests` naming the covering test classes; that is also the only thing that works in an org where an unrelated managed-package test class is broken. `"RunLocalTests"` runs every local test; `"NoTestRun"` is for a sandbox/scratch org only. A new class with no coverage fails the deploy, so **deploy the `@isTest` class in the SAME call** as the class it covers. Coverage shortfalls are reported as `Apex code coverage: …` alongside Salesforce's own message. To READ existing Apex, use `salesforce_tooling_soql` in two steps — `SELECT Id, Name FROM ApexClass WHERE Name = 'Foo'`, then `SELECT Id, Name, Body FROM ApexClass WHERE Id = '01p…'` (`Body` is only returned for a single-row query). A deploy REPLACES the whole class body, so read it first when editing. **`CustomApplication` — read first, the deploy REPLACES the whole app.** This is how an app's **navigation** is changed: `Metadata.tabs` is the ordered list of tab API names (`"standard-Account"`, `"My_Object__c"`) that makes up the nav bar. Two things to get right. (1) `full_name` is the app's **FullName**, not its DeveloperName — they differ for every namespaced app (`"standard__EasyMarketing"` vs `"EasyMarketing"`); `salesforce_list_applications` returns the correct value as `full_name`. (2) It is a read-modify-write: call `salesforce_get_application` first, edit the `Metadata` object it returns, send that whole object back. Anything omitted is REMOVED from the app — `label`, `description`, `brand`, `utilityBar`, `formFactors`, `actionOverrides`, `profileActionOverrides` and above all `tabs` itself. A payload with no `label` cannot be a full definition and is refused. Typical job: a new Custom Object Tab was added to every app by Setup's "Add to Custom Apps" step and has to come back out of the ones that should not have it — list the apps, then per app read → drop the tab from `Metadata.tabs` → deploy the edited object. Apps from a managed package (`dlrs__`, `APXTConga4__`; `deployable: false`) cannot be modified; Salesforce's own `standard` namespace can. The change is live for every assigned user the moment the deploy lands, so confirm the resulting tab list with the user and dry-run with `validate_only: true`. **`StandardValueSet` — read first, the deploy REPLACES the whole set.** These are the standard picklists that have no per-object field to edit (`TaskType`, `TaskSubject`, `EventType`, `LeadStatus`, `OpportunityStage`, `CaseStatus`, …). Workflow: (1) read the current values with `salesforce_tooling_soql` — `SELECT Id, MasterLabel, Metadata FROM StandardValueSet WHERE MasterLabel = 'TaskType'`; the returned `Id` is always the placeholder `000000000000000AAA` and is useless, the set is addressed by `MasterLabel`. (2) Send **all** of those values back plus your change — any value you leave out disappears from the picklist. (3) Retire a value by keeping it and setting `"isActive": false`; do not drop it, or records that already hold it carry a value nobody can see or re-select. The read calls a value's name `valueName` and the write calls it `fullName`; **both are accepted**, so a read payload can be edited and sent straight back. Body: `{ "sorted": false, "standardValue": [ { "fullName": "Outbound Call", "label": "Outbound Call", "default": false }, { "fullName": "WEBEX", "label": "WEBEX", "default": false, "isActive": false } ] }` — `default` is filled with `false` when omitted. Dry-run with `validate_only: true` first. Deliberately NOT supported — each has a typed tool behind a DIFFERENT write toggle, and routing it through here would silently bypass that toggle: `Flow` → `salesforce_save_flow`, `Layout` → `salesforce_save_layout`, `FlexiPage` → `salesforce_save_flexipage`/`salesforce_patch_flexipage`, `CustomObject` → `salesforce_create_object`, `CustomField` → `salesforce_save_field`. Profiles, Permission Sets, `StaticResource` (its content is usually binary, which a text `files` map cannot carry) and `ExperienceBundle` (hundreds of deeply-nested JSON files, and one deploy replaces a whole Experience Cloud site) are not reachable at all. **DELETION is not possible** (it needs `destructiveChanges.xml` — a separate, more dangerous operation). The package is ALL-OR-NOTHING (`rollbackOnError`): one bad component fails the deploy and nothing is saved. Changes are LIVE immediately — no draft state. Several components of the same object are merged into one document automatically. Max 25 components per call. `test_level` is `NoTestRun` | `RunSpecifiedTests` | `RunLocalTests` | `RunAllTestsInOrg`; for a config-only deploy you may omit it to let Salesforce apply its own default, but it is REQUIRED when the package contains Apex. If a deploy fails on Apex tests from an unrelated class (typically a broken managed package), re-run with `test_level: "RunSpecifiedTests"` and `run_tests` naming only the classes the change needs — those failures are reported as `Apex tests failed: …`, separately from component failures. Requires "Customize Application" + "Modify Metadata Through Metadata API Functions" on the run-as user. ### Deskpro tools (`deskpro` scope) Manage tickets, people, organizations and attachments across the Deskpro instances the user has connected (their own + any shared with them). Every action runs as the OWNER's run-as agent in Deskpro — when `is_owner=false` on the instance, the audit trail attributes to the owner, not the calling Lynk user. **Every ticket-bearing response carries `ticket_url`** — that's the canonical agent-UI deep link `/app#/t/ticket/`. Always cite that; the end-user portal path `/tickets/` 404s for agents. #### `deskpro_list_instances` Input: `{}`. Returns `{ count, instances: [{ id, name, base_url, last_test_ok, is_owner, owner_email, shared_with_me, api_key_hint, ... }] }`. Use the returned `id` as `instance_id` in every other Deskpro tool call. Call this first if you don't already know the id from earlier in the conversation. #### `deskpro_list_tickets` Input: `{ instance_id, status?, department?, agent?, person?, organization?, date_created?, order_by?, order_dir?, count?, page? }`. Lists tickets sorted newest-first by default. **Deskpro returns only ACTIVE tickets** (`awaiting_agent` / `awaiting_user` / `pending`) unless `status` is set to an exact inactive value (e.g. `resolved`, `archived`) — and there is **no single value** that unions active + resolved + archived. To sweep all states, query each status separately. `count` max 100. `date_created` is an ISO range like `"2026-01-01T00:00:00Z--2026-05-01T00:00:00Z"`. #### `deskpro_get_ticket` Input: `{ instance_id, ticket_id, messages_count?, include? }`. Returns `{ ticket, messages, ticket_url }` where `messages[]` is the full thread (replies + agent notes) in chronological order. Each message carries `attachments[]` as resolved objects `{ id, file_name, is_inline, download_url }` — Deskpro normally hands these out as bare integer ids, the gateway splices the URLs back in for you. Pass `download_url` to `deskpro_download_attachment` to fetch bytes. #### `deskpro_create_ticket` Input: `{ instance_id, subject, person_email, message, message_format?, is_agent_note?, person_first_name?, person_last_name?, department?, status?, priority?, urgency?, labels?, attachment_ids? }`. Creates a new ticket; if `person_email` doesn't match an existing user, Deskpro creates one. **`is_agent_note: true` (default false)** stages the FIRST message as an internal agent note instead of a customer-visible reply — the requester is NOT notified. Use this for test tickets or internal-only triage. Default `is_agent_note: false` means the first message is customer-visible and Deskpro sends the standard new-ticket mail. Returns the ticket envelope decorated with `ticket_url`. #### `deskpro_reply_ticket` Input: `{ instance_id, ticket_id, message, format?, is_agent_note?, attachment_ids? }`. Appends a message to an existing ticket. `is_agent_note: true` for an internal note (no customer mail), default `false` for a customer-visible reply (customer mail). Returns the created message envelope plus `ticket_url`. The gateway translates the boolean to Deskpro's wire-format `is_note` field — you don't need to know about the internal naming. **Converting an existing note into a customer reply is intentionally NOT exposed as a tool**: the only Deskpro path that flips a note AND mails the customer is the agent-UI button "Set Note as Reply"; the equivalent API call (`PUT /tickets/{id}/messages/{mid}` with `is_note: false`) flips the flag silently. To get a customer mail out from an LLM context, post a fresh customer-visible reply via this tool. #### `deskpro_save_draft_reply` Input: `{ instance_id, ticket_id, draft_message, format?, prefix?, attachment_ids? }`. Stages a proposed customer reply WITHOUT sending it. Internally implemented as an agent note with a visible `⚠️ ENTWURF — Bitte prüfen und absenden` header, so the human agent spots it in the thread, copies the text into the real reply box, edits if needed, and sends. Use this when an AI agent should draft a response but the human must approve before the customer sees anything. Deskpro has no public draft API; this is the closest reliable shape. Returns `{ data, ticket_url, draft_mode: true, draft_hint }`. #### `deskpro_update_ticket` Input: `{ instance_id, ticket_id, status?, agent?, agent_team?, department?, priority?, urgency?, labels? }`. Patches non-message ticket properties. Pass only fields you want to change. #### `deskpro_search_people` Input: `{ instance_id, search?, email?, organization?, is_agent?, is_user?, count?, page? }`. Search endusers and agents. `is_user: 1` restricts to endusers; `is_agent: 1` to agents. Returns paginated list with `id`, `name`, `primary_email`, `organization`. #### `deskpro_get_organization` Input: `{ instance_id, organization_id }`. Single organization by id. #### `deskpro_search_organizations` Input: `{ instance_id, search?, count?, page? }`. Paginated organization search by name. #### `deskpro_download_attachment` Input: `{ instance_id, download_url }`. Pass the `download_url` from a message attachment entry (returned by `deskpro_get_ticket`). Returns `{ content_type, filename, size_bytes, data_base64 }`. Max 50 MB per file. #### `deskpro_upload_attachment` Input: `{ instance_id, filename, mime_type?, data_base64 }`. Uploads a file as a temporary Deskpro blob. Returns `{ id }` — pass that blob id in `deskpro_create_ticket.attachment_ids` or `deskpro_reply_ticket.attachment_ids` to attach the file when posting the message. ### `lynk_request` Input: `{ method: 'GET' | 'POST', path: string, body?: object }`. Whitelisted paths (prefix match): - `GET /ip/v1/*`, `GET /bgp/v1/*`, `GET /api/files`, `GET /api/me`, `GET /api/usage`, `GET /api/projects`, `GET /api/loc-history`, `GET /api/project-meta`, `GET /health` - `GET /scrape/v1/*`, `POST /scrape/v1/*` Anything outside the whitelist returns an error. Use the dedicated tools when one exists — `lynk_request` is for niche endpoints that don't have a wrapper yet. ### `netsuite_list_instances` List the NetSuite accounts you have connected. Returns `{ count, instances: [{ id, name, account_id, credentials_hint, last_test_ok, ... }] }`. Use the `id` as `instance_id` for every other NetSuite tool. All NetSuite tools are read-only. ### `netsuite_suiteql` Input: `{ instance_id: number, q: string, limit?: number, offset?: number }`. Runs a read-only SuiteQL query — SELECT / WITH only (JOINs, GROUP BY, aggregates and `BUILTIN.*` functions supported). `limit` max 1000 (default 100). The response carries `items[]`, `hasMore`, `totalResults` — page with `offset`. Queries that don't start with SELECT/WITH are rejected. ### `netsuite_get_record` Input: `{ instance_id: number, record_type: string, record_id: string, expand_sub_resources?: boolean, fields?: string }`. Fetches one record by internal id via the REST record API. `expand_sub_resources` inlines sublist bodies; `fields` is a comma-separated allow-list. ### `netsuite_list_records` Input: `{ instance_id: number, record_type: string, q?: string, limit?: number, offset?: number }`. Lists internal ids + links for a record type (not full bodies — fetch each with `netsuite_get_record`, or use `netsuite_suiteql` for bulk fields). `q` uses NetSuite's REST filter syntax, e.g. `email CONTAIN "acme.com" AND isInactive IS false`. ### `netsuite_record_metadata` Input: `{ instance_id: number, select?: string }`. Returns the REST metadata catalog — record types the account exposes and their field schemas. Omit `select` for all types; pass a comma list (e.g. `customer,invoice`) to narrow. ### `netsuite_get_file` Input: `{ instance_id: number, file_id: number }`. Downloads a File Cabinet file by internal id. Returns `{ id, name, folder, type, size, encoding, content }` where `encoding` is `base64` for binary files (PDF, images, …) and `utf-8` for text. **Requires the per-instance RESTlet (`lynk_file_reader_rl.js`) to be deployed in NetSuite and its Script + Deployment ids saved on the dashboard NetSuite page** — SuiteTalk REST has no native File Cabinet content endpoint, so this bridge is unavoidable. Tool returns a clean error with setup instructions when the RESTlet isn't configured. ### `netsuite_get_vendor_bill_pdf` Input: `{ instance_id: number, vendor_bill_id: string }`. Fetches the supplier-invoice PDF for a vendor bill. Reads the bill, extracts the PDF File Cabinet id from the AP-automation custom field `custbody_ff_sc_b2pmodel.pdfInternalId` (Yooz / Storecove / Fastfour SuiteApps), and downloads the file via the same RESTlet `netsuite_get_file` uses. Returns `{ vendor_bill_id, pdf_file_id, id, name, folder, type, size, encoding, content }`. Only works on accounts using a compatible AP-automation SuiteApp — bills without that field return a "no associated PDF" error. > **NetSuite writes are OWNER-ONLY.** The four tools below only work on connections where `is_owner=true` in `netsuite_list_instances` (shared members read but never write), write to the live ERP, and are bounded by the NetSuite role's own create/edit permissions. Confirm the concrete details with the user before calling. ### `netsuite_create_vendor` Input: `{ instance_id: number, company_name?: string, is_person?: boolean, first_name?: string, last_name?: string, email?: string, subsidiary_id?: string, fields?: object }`. Creates a vendor (AP master data). Pass `company_name` for a company, or `is_person=true` + first/last name for an individual. On OneWorld accounts `subsidiary_id` is required. `fields` merges any extra native/custom vendor field verbatim. Returns `{ ok, record_type: "vendor", id, location }`. ### `netsuite_update_vendor` Input: `{ instance_id: number, vendor_id: string, email?: string, fields?: object }`. Updates fields on an EXISTING vendor by internal id (REST PATCH — only the fields you pass change, the rest is untouched). Because it patches the record directly rather than through an entry form, form-level mandatory-field rules don't block it the way a UI edit or CSV import would — ideal for backfilling one field (email, terms, a `custentity_*`) across many vendors. List/record fields take a ref object (e.g. `{"terms":{"id":"20"},"custentity_x":{"id":"3"}}`); scalar fields take the raw value. Find the vendor id + target list-value ids via `netsuite_suiteql`. Returns `{ ok, record_type: "vendor", vendor_id, updated_fields, id, location }`. ### `netsuite_create_credit_memo` Input: `{ instance_id: number, fields: object }`. Creates a customer credit memo (AR "Gutschrift"). `fields` is the full NetSuite `creditMemo` body — at minimum the customer ref + line items, e.g. `{ "entity": { "id": "123" }, "item": { "items": [{ "item": { "id": "55" }, "amount": 100, "quantity": 1 }] } }` (add `"subsidiary": { "id": "1" }` on OneWorld). Discover the field/sublist shape with `netsuite_record_metadata` (`select="creditMemo"`). Returns `{ ok, record_type: "creditMemo", id, location }`. ### `netsuite_set_invoice_dunning_hold` Input: `{ instance_id: number, invoice_id: string, hold?: boolean, field?: string }`. Puts an invoice into (default `hold=true`) or out of dunning hold so NetSuite generates no further dunning letters/reminders for it. Sets the per-instance configured dunning-hold field (account-specific — configure it once on the dashboard NetSuite page, or override per call with `field`, e.g. `custbody_ns_dunning_paused`; the field id is also typically described in `instance_context`). Find invoice ids via SuiteQL: `SELECT id, tranid FROM transaction WHERE type='CustInvc'`. Returns `{ ok, record_type: "invoice", invoice_id, field, hold, id }`. ### `netsuite_update_instance_config` Input: `{ instance_id: number, instance_context?: string|null, instance_dont?: string|null, dunning_hold_field?: string|null }`. OWNER-ONLY. Persists Lynk-side, account-specific config for the instance — does NOT write to NetSuite. `instance_context` = free-text FACTS/conventions the agent reads (via `netsuite_list_instances`) before building writes; `instance_dont` = hard DO-NOT rules / guardrails the agent must respect; `dunning_hold_field` = the typed dunning field id the dunning-hold tool uses directly. Use it when the user states a fact or rule in chat ("dunning stop field is custbody_av6_block_invoice (boolean)", "never create credit memos over 1000€ without asking"). Omit a field to keep it, "" / null to clear. `netsuite_list_instances` surfaces `instance_context`, `instance_dont`, `dunning_hold_field` and `restlet_configured` on every entry. ### CRM tools (`crm` scope) A light CRM / outreach module. A workspace is identified by `workspace_id` (a Lynk user id); every CRM tool takes an optional `workspace_id` — omit it for your own workspace, or pass one returned by `crm_list_workspaces` to operate on a workspace shared with you. **The CRM tools are populate-only: an AI can research and write contacts/companies/templates and create DRAFT campaigns, but cannot send mail or activate campaigns — a human does that from the Lynk dashboard.** - `crm_list_workspaces` — list workspaces you can access (`{ workspace_id, owner_email, role, is_self }`). - `crm_list_contacts` — `{ workspace_id?, search?, status?, tag?, limit?, offset? }` → `{ contacts[], total }`. - `crm_get_contact` — `{ workspace_id?, contact_id }` → contact + full activity timeline. - `crm_create_contact` / `crm_update_contact` — create/enrich a contact (name, email, company_id, title, phone, status, tags, notes). - `crm_list_companies` / `crm_create_company` / `crm_update_company` — company (account) records; `enrichment` is a free-form object for structured research. - `crm_add_activity` — `{ workspace_id?, contact_id, type, body }`; use type `research` to log findings. - `crm_list_templates` / `crm_create_template` / `crm_update_template` — reusable email templates; merge fields `{{first_name}} {{name}} {{company}} {{title}} {{email}}`. - `crm_list_campaigns` / `crm_get_campaign` — list campaigns + recipients/stats. - `crm_create_campaign` — creates a DRAFT campaign over a contact segment (`filter`) with an optional email part (`email_enabled`, `subject`, `body`, follow-up) and/or call part (`call_enabled`, `call_script`). Never sends. - `crm_add_call_task` — `{ workspace_id?, contact_id, priority? }`; queues a contact onto the daily call list. - `crm_list_call_tasks` — open call tasks (due callbacks first, then oldest queued). ### GEO Tracker tools (`geo` scope) A "Generative Engine Optimization" tracker — measure how a brand appears in AI search engines over time. **Read-mostly: AI agents can observe and trigger runs, but project setup (creating a project, editing prompts) stays dashboard-only.** Each project is one tracked domain plus a set of brand aliases, competitor names, prompt suggestions and selected LLM models. - `geo_list_projects` — `{}` → all your GEO projects with prompt counts + last-run info. - `geo_list_prompts` — `{ project_id }` → all prompts for a project (active + inactive), each with `stage` (`awareness`/`consideration`/`decision`) and `intent_type` (`informational`/`commercial`/`transactional`). - `geo_get_project_report` — `{ project_id, from?, to? }` (dates YYYY-MM-DD, default last 30 days) → `{ mention_rate: { points[], sample_size }, share_of_voice: { brand_mention_rate, by_competitor }, top_citation_domains[] }`. The single most useful tool for AI analysis — gives you mention-rate trend per model, brand vs competitor share, and which sources the AI models reference. - `geo_run_project` — `{ project_id }` → kicks off a manual run (async; returns `run_id` immediately). **PAYG users are billed `ceil(api_calls / 10)` Lynk tokens per run** — typically 50-150 tokens for a default project (5 models × 30 prompts × 3 repetitions × 2 calls = 90 tokens). Returns `insufficient_tokens` error if the user's balance is short. ### Personio tools (`personio` scope) Read-only HR + recruiting access to the user's connected Personio tenants. Two independent APIs in one connection — HR (OAuth2 client_credentials → JWT) and Recruiting v1 (static API key + Company-ID). **Sensitive HR fields are redacted in MCP responses BY DEFAULT.** The list (`PERSONIO_SENSITIVE_FIELDS`): `salary`, `iban`, `bic`, `tax_id`, `tax_class`, `social_security_number`, `date_of_birth`, `private_address`, `private_address_line_2`, `private_city`, `private_zip`, `private_country`, `private_phone`, `private_mobile_phone`, `private_email`. Every occurrence (at any nesting depth) is replaced with `"__REDACTED__"` before the response reaches the AI agent. **Owner opt-in:** a connection's workspace **owner** can enable sensitive-field MCP access per connection (`personio_list_instances` reports it as `sensitive_mcp_enabled`); when enabled AND you call as that owner, the employee tools return the raw values. Non-owner members + shared members always stay redacted. (The REST API has the parallel owner-only `?include_sensitive=1`.) **Each connection can have HR-only, Recruiting-only, or both**; call `personio_list_instances` first to see which side is configured. Discovery + HR data: - `personio_list_instances` — `{}` → every Personio connection accessible to you (connections in your workspaces PLUS connections shared with you individually). Each entry: `id`, `name`, `workspace_name`, `workspace_slug`, `hr_configured`, `recruiting_configured`, `hr_last_test_ok`, `recruiting_last_test_ok`, `is_owner` (you can manage it — workspace member), `shared_with_me` (read-only per-connection share), `sensitive_mcp_enabled` (owner opted this connection in to sensitive-field MCP access). - `personio_list_employees` — `{ instance_id, email?, updated_since?, limit?, offset? }` → employee directory. Sensitive fields redacted unless the owner opted in AND you call as the owner (see scope note). `limit` default 50, max 200. - `personio_get_employee` — `{ instance_id, employee_id }` → one employee, same redaction rule. - `personio_list_custom_attributes` — `{ instance_id }` → tenant's custom HR field schema (no PII — never redacted). - `personio_list_time_offs` — `{ instance_id, start_date?, end_date?, employee_ids?, limit?, offset? }` → time-off periods overlapping the window. Default window: today through 14 days. - `personio_list_time_off_types` — `{ instance_id }` → tenant's configured leave types (Vacation, Sick, Parental Leave, …) for interpreting the type field in time-off rows. - `personio_list_attendances` — `{ instance_id, employee_ids (REQUIRED, min 1), start_date, end_date, limit?, offset? }` → clock-in / clock-out entries. Personio does not allow a tenant-wide listing — you MUST pass an explicit employee list. Pre-aggregated reports (prefer these for the common questions): - `personio_absence_report` — `{ instance_id, days_ahead? (default 7, max 60) }` → `{ window, total, by_type, by_department, entries[] }`. Each entry has `employee_id`, `employee_name`, `department`, `type`, `start_date`, `end_date`, `days`, `status`. Joins time-offs to employee names + departments in a single tool call. **Use this instead of chaining `personio_list_employees` + `personio_list_time_offs` for "who is out this week".** - `personio_pipeline_funnel` — `{ instance_id, updated_since? (default 90 days ago) }` → `{ window_since, total_applications, by_status, by_job_position[] }`. Each job_position bucket has `job_position_id`, `job_position_name`, `status_counts`, `total`. **Use this instead of `personio_list_applications` for "how is recruiting doing?".** Recruiting raw data: - `personio_list_job_positions` — `{ instance_id }` → open positions on the careers page. - `personio_list_applications` — `{ instance_id, job_position_id?, status?, updated_since?, limit?, offset? }` → applications, filtered. Candidate name + email + phone DO pass through (recruiting IS the purpose of this API — they are not in PERSONIO_SENSITIVE_FIELDS). - `personio_get_application` — `{ instance_id, application_id }` → one application with stage + history. Write paths (stage moves, comments on applications, employee edits) are **not** exposed — Personio v2 Recruiting + auto-write will arrive as a separate `personio_recruiting_v2_*` family in a later phase, gated behind per-job-position eval-based auto-enable. ### Tech Scanner tools (`tech` scope) Wrapper around three BuiltWith API endpoints. All three live behind one admin-managed `BUILTWITH_API_KEY` — Lynk eats the BuiltWith bill and bills users in Lynk tokens. Tech stacks change slowly, so the cache TTLs are generous (30d for lookups, 7d for site lists, 24h for trends). 'unknown' / upstream-error verdicts are never cached. - `tech_lookup` — `{ domain?: string, domains?: string[], format?: 'json' | 'markdown', no_cache?: boolean }`. Returns the technologies BuiltWith has detected per domain — CMS, frontend frameworks, analytics, ads, hosting, payments, CRM, etc — plus company metadata (vertical, country, employees, estimated monthly tech spend). Pricing: **5 tokens per domain**. Max 50 domains per call. `format='markdown'` produces a category-grouped document for dropping into LLM prompts. `found: false` means BuiltWith has no record for that domain — not every domain on the internet has been crawled. - `tech_find_sites` — `{ tech: string, offset?: string, pages?: number, format?, no_cache? }`. Returns websites BuiltWith has detected using a specific technology (e.g. `Shopify`, `Google-Analytics`, `HubSpot` — exact tag from BuiltWith's UI). Pricing: **10 tokens per BuiltWith Lists page**. `pages` (default 1, max 5) auto-fetches multiple pages in one call — ask the user before setting `pages > 1`. Pagination via opaque `next_offset` cursors — pass the previous response's `next_offset` back as `offset` to continue. Each page returns ~50–500 sites depending on the tech's popularity. **Casing note**: Lynk auto-corrects `tech` against the catalog of tags it has seen in real Domain API responses on this instance — `cloudflare` silently becomes `Cloudflare`. Unknown tags pass through unchanged. Browse the full known-tag catalog via the public `GET /tech/v1/tags` endpoint (no auth, no token cost). - `tech_trends` — `{ tech: string, format?, no_cache? }`. Returns BuiltWith's adoption-coverage stats: how many of the top-10k / top-100k / top-1M / all live sites use the tech, plus historical drops. Pricing: **1 token per call** (BuiltWith's Trends endpoint is free upstream, so this is essentially Lynk bookkeeping). Useful for sizing a tech's installed base before pitching. Same case-correction as `tech_find_sites`. **Discovery flow that works well**: first `tech_lookup` on the prospect's domain to see what they currently use → `tech_trends` on each competitor of yours that's NOT yet on their stack to size the upgrade market → `tech_find_sites` on adjacent technologies to surface similar prospects to outreach. The cache TTLs are sized to make this kind of multi-step discovery affordable. ### Canvas tools (`canvas` scope) Host a single self-contained HTML document and get a public shareable link. The right tool when you have GENERATED html (report, chart page, dashboard, mockup) and the user wants to *view/share* it, not receive raw markup. - `canvas_publish` — `{ html?: string, markdown?: string, source_url?: string, title?: string, protect?: boolean, password?: string, password_kind?: 'password'|'pin', expires_in_days?: number, slug?: string, folder_id?: string, visibility?: 'public'|'restricted'|'private', allowed_emails?: string[], editor_emails?: string[], send_invites?: boolean, kv_mode?: 'read_write'|'write_only', notify_email?: string, notify_webhook?: string, notify_keys?: string }`. **Access (no folder needed):** `visibility="restricted"` + `allowed_emails` shares a SINGLE page with specific signed-in Lynk users (max 50); `editor_emails` (subset) may also EDIT its content (never delete/reconfigure); `visibility="private"` = you only. The allowlist is set either way; **invite emails are opt-in** — pass `send_invites: true` to email each allowed/editor address (a magic-link sign-in for unknown emails, a notification for existing Lynk users; ask the user before emailing third parties). Default `false` = no mail (listed users just sign in to Lynk with that email). **`kv_mode`:** `'read_write'` (default — public KV API can read+write) or `'write_only'` (public can set/incr/push but the public GET/DELETE return 403 and push doesn't echo the array — the collect-only form/RSVP/poll path where respondents must not see each other's entries; you still read everything via `canvas_kv_get`). Provide EXACTLY ONE of `html` (one-shot HTML), `markdown` (one-shot Markdown — see below), `source_url` (a public http(s) URL Lynk fetches server-side to use AS the page HTML — for HTML already at a URL, so it never passes through your context; SSRF-guarded, ≤~3 MB), or **NONE → the tool mints a presigned PUT URL** and returns `{ mode:"upload_url", upload_url, method:"PUT", expires_at }` with the metadata baked into the ticket; PUT the page HTML straight from disk with no auth header (`curl -X PUT -H "Content-Type: text/html" --data-binary @page.html ""`, valid ~30 min) so a large/local page never passes through your context. **MARKDOWN:** pass `markdown` instead of `html` to publish a text document (report, notes, README, changelog) — the gateway renders it server-side to a styled, self-contained, mobile-friendly, light/dark-aware HTML page (headings, **bold**/*italic*, lists, tables, fenced code, blockquotes, links, images), so you don't hand-write HTML/CSS. The page round-trips: a later `canvas_get`/`canvas_update` sees it as `markdown`. `folder_id` files the page in a folder (see `canvas_list_folders` / `canvas_create_folder`); the page inherits the folder's access as a floor. Publishes the document and returns `{ id, url, short_url, short_host_url, slug, has_password, is_pin, expires_at, size, tokens_charged }`. **Give the user the returned `url` (or `short_url` when set) verbatim** — don't reconstruct it. `short_host_url` (`https://canvas.lynk.run/`) is an optional shorter alias that 301-redirects to `url`; offer it only if the user wants the shortest shareable link — `url` stays canonical. **To protect a page, prefer `protect: true`** — the server mints a random 6-digit access code and returns it ONCE as `generated_password` in the result (give it to the user verbatim); do NOT invent your own PIN, and only pass an explicit `password` when the user chose a specific code. `password_kind` picks the gate style for an explicit `password`: `'password'` (free-form, default) or `'pin'` (exactly 4 digits — the viewer shows a segmented PIN pad). `slug` is an optional memorable short URL (`https://lynk.run/canvas/`, 3–64 chars lowercase letters/digits/dashes) — **guessable by design, not for private content**. **Privacy default:** a Canvas page is public to anyone with the link (no login on the page). If the HTML carries sensitive / internal / personal / confidential data, set a `password` (or 4-digit `pin`) and skip the `slug`; if unsure whether it should be public, ask the user before publishing. **What the password protects:** the page HTML — a protected page's content is served only after the correct password/PIN is entered, there is no public endpoint that returns it, and knowing the page id does not bypass the gate. Do not conflate this with the public KV store (a separate opt-in scratch space holding only what the page JS writes — a protected page does not expose its HTML through it). Page content IS encrypted at rest (AES-256-GCM with a server-held key — quantum-resistant symmetric crypto), so a DB/backup leak exposes only ciphertext. Honest limits to mention calmly only for truly sensitive data: this is access-controlled hosting with key separation, NOT zero-knowledge (the running server holds the key and decrypts to render, so it's not end-to-end encrypted against Lynk itself), and it's external hosting on lynk.run; for most internal reports/dashboards a password-gated page is appropriate. Pricing: **2 tokens per publish** (`CANVAS_TOKENS_PER_PUBLISH`); unlimited-plan + admin users are free. `expires_in_days` omitted ⇒ evergreen; 1–365 ⇒ auto-deletes. Max ~2 MB of HTML. **Entry notifications:** `notify_email` and/or `notify_webhook` fire when a visitor submits a NEW entry to the page's KV store (a public `push`/`set` — the form/RSVP/guestbook path; counter `incr` does not trigger). `notify_email` → an email with the submitted value; `notify_webhook` → an http(s) URL POSTed `{ event:'canvas.kv.entry', page_id, slug, title, view_url, key, op, value, at }` (value = the single new entry). Public hosts only (private/loopback/metadata blocked, no redirect-following — SSRF guard); hard per-page hourly rate limits. `notify_keys` (comma-separated, exact case-sensitive KV-key match, e.g. `"responses,rsvp"`) optionally limits which keys fire the notification — empty/omitted = every entry fires; use it when the page also writes other keys (a counter, draft state). Pairs well with a write-only KV page (REST/dashboard `kv_mode`). - `canvas_update` — `{ id, html?, markdown?, title?, protect?, password?, password_kind?, expires_in_days?, slug?, folder_id?, visibility?: 'public'|'restricted'|'private', allowed_emails?: string[], editor_emails?: string[], send_invites?: boolean, kv_mode?: 'read_write'|'write_only', notify_email?, notify_webhook?, notify_keys? }`. Edits a page **IN PLACE** — same id, URL, short link and KV store, so links you already shared keep working with the new content. `visibility`/`allowed_emails`/`editor_emails` change the page's access directly (omit = keep; `allowed_emails`/`editor_emails` REPLACE the lists). Invite emails are opt-in via `send_invites: true` — only genuinely NEW allowlist entries are mailed (no re-spam on an edit that keeps the same people). `kv_mode` switches the public KV access mode (omit = keep; see `canvas_publish`). **Use this instead of `canvas_publish` to change a page you already shared** (publish would mint a new id/URL). Pass `html` OR `markdown` OR `source_url` (a public http(s) URL Lynk fetches server-side — SSRF-guarded, ≤~3 MB) to replace the content; **pass `html` with `append: true` to CONCATENATE a chunk onto the existing HTML** (the build-up path when you can't emit a whole document in one call — `canvas_publish` the first chunk, then `canvas_update({ id, html: chunk, append: true })` repeatedly; HTML-only, FREE, no version entry); OR set `html_upload: true` with NONE of those → the tool mints a presigned PUT URL (`{ mode:"upload_url", upload_url, … }`) to curl the new HTML from disk (`curl -X PUT -H "Content-Type: text/html" --data-binary @page.html ""`, ~30 min) so a large/local page never passes through your context (keeps the same id/URL/KV). Without `html_upload`, omitting the content keeps the current HTML (a free metadata-only edit). For a page originally published from Markdown, fetch its source via `canvas_get` (the `markdown` field), edit that, and send it back as `markdown`. Every field is "omit = keep": empty string clears `title`/`slug`/`password`/`notify_email`/`notify_webhook`/`notify_keys`, `expires_in_days: 0` removes expiry, `folder_id: ""` detaches from any folder; `password_kind` applies only when you set a new password. **To add protection to a currently-unprotected page without choosing a code, pass `protect: true`** — the server mints a random 6-digit code and returns it once as `generated_password` (no effect if the page is already protected or you pass an explicit `password`). `notify_email`/`notify_webhook`/`notify_keys` configure entry notifications (see `canvas_publish`). Costs 2 tokens **only when the content changes** (metadata-only edits are free); unlimited-plan + admin free. Returns the same shape as `canvas_publish`. - `canvas_list` — `{ limit?: number }`. Lists your pages PLUS pages shared with you as an editor (metadata only, no HTML body): id, title, size, view_count, has_password, expiry, `slug`, `view_url`, `short_url`, `folder_id`, `is_owner` (false for a shared page), `can_edit`. The roster (`allowed_emails`/`editor_emails`) is only returned for pages you own. - `canvas_get` — `{ id: string }`. Returns one page **including its full `html`** plus `content_format` (`'html'`|`'markdown'`) and `is_owner`. Works for pages you own AND pages shared with you as an editor (a viewer-only ACL entry can't fetch the source — only an `editor`). When the page was published from Markdown it also returns the original `markdown` source — edit THAT and send it back via `canvas_update`'s `markdown` field to round-trip cleanly. - `canvas_delete` — `{ id: string }`. Deletes the page; the public link 404s immediately. **Owner-only** — a shared editor can edit content but cannot delete. - `canvas_list_folders` — `{}`. Lists your folders PLUS folders shared with you as an editor: `{ count, folders: [{ id, name, visibility, allowed_emails, page_count, url, is_owner }] }`. A folder groups pages under a shared access policy (`public`/`restricted`/`private`); pages filed under it (`folder_id`) inherit that policy as a FLOOR (a page can be stricter, never looser). The folder `url` (`https://lynk.run/canvas/folder/`) shows an index of the pages a viewer is allowed to open. A folder-editor may edit the folder's name/slug/homepage and the content of pages inside it (`canvas_update_folder` / `canvas_update`), but never delete or re-share. - `canvas_create_folder` — `{ name?, visibility?: 'public'|'restricted'|'private', allowed_emails?: string[], send_invites?: boolean }`. Creates a folder; returns `{ id, name, visibility, allowed_emails, url }`. Feed `id` to `canvas_publish`/`canvas_update` as `folder_id`. For `restricted`, `allowed_emails` are the Lynk-user emails allowed to view. Invite emails are opt-in: pass `send_invites: true` to email each allowed address (magic-link for unknown emails, notification for existing users; ask the user first). Default `false` = allowlist set, no mail (listed users sign in to Lynk with that email). - `canvas_update_folder` — `{ folder_id, name?, visibility?, allowed_emails?, send_invites?: boolean, slug?, index_page_id? }`. Updates a folder IN PLACE; "omit = keep". Returns the updated folder `{ id, name, slug, index_page_id, visibility, allowed_emails, page_count, url }`. `slug` → a pretty folder URL `https://lynk.run/canvas/folder/` (3–64 chars lowercase letters/digits/dashes, globally unique across folders; empty string clears). `index_page_id` → designate a page IN this folder as the HOMEPAGE: opening the folder link then 302-redirects to that page instead of the page listing (empty string reverts to listing; the page must belong to this folder — get page ids + `folder_id` from `canvas_list`). This is how an agent finishes a multi-file site (publish pages → upload assets → set slug + homepage). For `restricted`, `allowed_emails` replaces the allowlist; `send_invites: true` emails only the newly-added entries. There is intentionally NO tool to DELETE a folder (destructive cascade — removes the folder AND every page + asset in it — dashboard-only). - `canvas_upload_asset` — `{ folder_id, key, content_base64? | source_url? }`. Uploads a subresource (image / CSS / JS / font) into one of your folders and returns `{ url, key, size, mime_type }`. Provide the bytes ONE of THREE ways: `content_base64` (raw bytes base64-encoded), `source_url` (a public http(s) URL Lynk fetches server-side — PREFER this for anything but tiny files so you don't inline a large base64 blob; SSRF-guarded, redirects not followed, ≤10 MB), OR **NEITHER → the tool mints a presigned upload URL** and returns `{ mode:"upload_url", upload_url, method:"PUT", field:"file", expires_at }`; curl the file straight from disk with no auth header (`curl -X PUT -F "file=@logo.png" "?key=assets/logo.png"`, valid ~30 min) so a large asset never passes through your context. The Content-Type is derived from the `key` file extension (images, CSS, JS, fonts only — HTML is rejected; SVG is allowed but served sandboxed). A folder-member page gets a `` injected, so reference an asset relatively (``) or via the returned absolute `url`. In-page `#anchor` links still behave normally — the injected base would otherwise resolve them to the folder index, so the gateway ships a fragment-navigation fix alongside it. The returned `url` is a clean public cross-origin resource — it embeds correctly from the sandboxed (opaque-origin) page as a plain ``, an ``, a `fetch()`, a canvas pixel read, or under COEP. This turns a folder into a small multi-file website (host images/CSS/JS once, share across the folder's pages, instead of base64-inlining into each page's 2 MB HTML). Per-file max 10 MB, 100 MB total per user; same `key` overwrites. **Capability-public**: the folder UUID is unguessable but assets are NOT additionally ACL-gated — don't upload secret images to a restricted folder. NOTE: there is no MCP tool to delete a folder (folder deletion is destructive — it removes the folder AND every page + asset in it — and is intentionally dashboard-only); `canvas_delete` removes a single page. - `canvas_list_assets` — `{ folder_id }`. Lists the assets uploaded into one of your folders: `{ folder_id, count, assets: [{ key, mime_type, size, created_at, url }] }`. Use it to see what's already uploaded before overwriting or cleaning up. - `canvas_delete_asset` — `{ folder_id, key }`. Deletes one asset by its `key`; the public asset URL stops working immediately (any page still referencing it 404s that subresource). Irreversible. Returns `{ ok, key }`. A folder can also designate one of its pages as a **homepage**: the folder link then 302-redirects to that page instead of showing the page listing, and a folder can carry its own `slug` (`https://lynk.run/canvas/folder/`). Set both via `canvas_update_folder` (or from the dashboard). A page is also reachable via the short host `https://canvas.lynk.run/`, which 301-redirects to the canonical `https://lynk.run/canvas/`. **Direct REST (non-MCP) — raw-HTML / raw-Markdown body.** Outside MCP, `POST /api/canvas` and `PUT /api/canvas/:id` accept the JSON envelope (`Content-Type: application/json`, `{ html, title?, … }` — add `"format":"markdown"` or a `"markdown"` field to render Markdown) **or** a raw document body with metadata on the query string (`?title=&slug=&password=&password_kind=&expires_in_days=`): `Content-Type: text/html` (the body IS the HTML page) or `Content-Type: text/markdown` (the body IS Markdown source, rendered server-side to a styled page). The raw-body form avoids JSON-escaping a whole document (the main cause of broken publish calls from scripts/CI), e.g. `curl -X POST https://api.lynk.run/api/canvas -H 'Authorization: Bearer ' -H 'Content-Type: text/markdown' --data-binary @report.md`. All shapes bill identically (caps apply to the rendered HTML). The MCP `canvas_publish`/`canvas_update` tools take `markdown` as a structured argument instead. **The HTML must be self-contained** — inline all CSS/JS; external URLs (CDN, images) still load but there is no separate asset upload. **Pages render in a sandboxed opaque origin** (served under `Content-Security-Policy: sandbox`): scripts run, but the page cannot read cookies / `localStorage` or call same-origin APIs. That's an intentional security boundary, not a bug — if a user's storage-based code doesn't work, that's why. **Two things DO work under the sandbox**: (1) **external links** — a click-opened `` / `window.open(url,'_blank')` opens as a normal un-sandboxed tab on the destination's origin (the CSP grants `allow-popups-to-escape-sandbox`), so an "Open in Deskpro ↗" button loads fine; the page still can't read that site's cookies *itself*, only the popup escapes. (2) **deep-linking** — the page is the top document, so it reads `?query`/`#hash` on load and `history.pushState`/`replaceState` updates the real shareable address-bar URL live; keep ONE Canvas URL and deep-link per record (`…/canvas/?ticket=5051`) instead of minting a page per record. **React pages** are supported as long as they are ONE self-contained file: load `react` + `react-dom` from a CDN (UMD `unpkg` + `@babel/standalone` for in-browser JSX, or an ESM import-map to `esm.sh`), or inline a pre-built single bundle. External CDN scripts don't count toward the 2 MB cap. Multi-file projects / Next.js / Vite repos can't be hosted as-is — reduce to one HTML file first. In the sandbox, use `HashRouter` (not `BrowserRouter`) and the Canvas KV API instead of `localStorage`. **Per-page mini-DB (key-value store).** Each page has a small server-side KV store for persistence (visit counters, guestbooks, polls, shared state). Two ways to use it: - `canvas_kv_get` (`{ id, key?, prefix? }` — omit `key` for the full `{key:value}` map; pass `prefix` for only keys starting with it, e.g. `"contract:"`, the PARTIAL-READ path so you don't pull the whole store), `canvas_kv_set` (`{ id, key?, value?, data?: {key:value} }` — THREE modes: set ONE key (`key`+`value`); set MANY at once (`data` map — the atomic SEED path, all caps validated against the final state so an over-cap bulk is rejected whole, existing keys overwritten/others untouched, returns `{ count, total_bytes }`); or **OMIT both `key` and `data` → the tool mints a presigned upload URL** (`{ mode:"upload_url", upload_url, method:"POST", expires_at }`) and you POST the `{key:value}` map straight from a file with no auth header (`curl -X POST -H "Content-Type: application/json" --data-binary @seed.json ""`, ~30 min) so a large seed never passes through your context), `canvas_kv_delete` (`{ id, key? }` — omit key to clear all). Owner-scoped. Caps: 100000 keys/page, 256 KB/value, 10 MB/page total — the 10 MB total is the real per-view ceiling (whole-map reads ship everything; use `prefix` for big stores). - From inside the published HTML, over a **public CORS HTTP API** the page calls at runtime. Derive the id from the URL and use GET (read) + POST (write/incr) — both preflight-free; avoid PUT/DELETE from page JS: - `GET /canvas//kv` → `{ data: { key: value } }`; `GET /canvas//kv/` → `{ key, value, updated_at }` (404 if unset) - `POST /canvas//kv/` (body = the value) → upsert; `POST /canvas//kv//incr` (optional `{by}`) → `{ value }` atomic counter - `POST /canvas//kv//push` (body = the item; JSON-parsed when valid JSON, else stored as a string) → atomic append to a JSON-array value; optional `?max=N` (or `{max}`) turns the key into a FIFO ring buffer (oldest items dropped). Returns `{ value: array, length }`. This is the **leaderboard / guestbook / feed** path — race-free under concurrent writers, so prefer it over read-modify-write of a whole array. - `DELETE /canvas//kv/` → delete one key - **Public read + write** (anyone with the link can write — for counters/leaderboards/guestbooks/shared state, never secrets). **This applies to the KV store only, not the page's password-gated HTML** — the KV store holds only what the page JS writes, so a protected page does not expose its content through it. Caps: 2000 keys/page, 256 KB/value, 10 MB/page, 1000 items per array key; per-IP rate-limited. Big enough to be the data store behind a UI-shell page (one key per record). Values are strings (stringify JSON yourself) and are encrypted at rest server-side (transparent — you always read/write plaintext). Deleting/expiring the page wipes its KV (FK cascade). - **KV in restricted/private pages & restricted folders works** — do NOT make a page public or pull it out of its folder to "fix" KV. For any effectively non-public page (restricted/private itself, or a public page inside a restricted folder) the gateway auto-injects a page-scoped token into every `/canvas//kv…` fetch, so the page's KV code keeps working unchanged. A `NetworkError` there is almost always a PUT/DELETE call (preflight-blocked from the sandboxed null-origin) — use GET/POST. The token is only minted to logged-in, allow-listed users, so under restriction KV writers must be members; for anonymous (not-logged-in) public KV writes the page and its folder must be `public`. - **Signed-in viewer identity on non-public pages** — a restricted/private page (or a public page inside a restricted folder) gets the verified viewer injected as `window.__lynk_user = { email, name }` (name may be null; the internal user id is deliberately not exposed). Page JS reads it to skip a name prompt in live-collaboration pages, show presence, or key per-user state on the stable `email` (the sandboxed page can't read cookies, so this is the only identity source). Undefined on public pages (anonymous — never injected, by privacy design). Comfort, not tamper-proof attribution (a co-allow-listed user can still POST a different name to KV). ### Gmail tools (`gmail` scope) Full read+write access to the user's connected Gmail accounts. Accounts are **personal** — there is no sharing. Every tool takes `account_id` (from `gmail_list_accounts`). The connect flow lives in the dashboard at `https://lynk.run/gmail`. - `gmail_list_accounts` — `{}` → `{ count, accounts: [{ id, email, healthy, needs_reconnect, connected_at }] }`. `id` is the `account_id` for every other tool. If `needs_reconnect` is true the user must re-connect in the dashboard. - `gmail_search` — `{ account_id, q?, max?, page_token? }` → matching `{ messages: [{id, threadId}], next_page_token, estimate }`. `q` is **Gmail search-box syntax**: `from:alice@acme.com`, `subject:invoice`, `has:attachment`, `is:unread`, `label:important`, `newer_than:7d`, `after:2026/01/01`. Bodies are NOT returned — fetch with `gmail_get_message`. - `gmail_get_message` — `{ account_id, message_id }` → parsed `{ id, thread_id, label_ids, from, to, cc, subject, date, body_text, body_truncated, attachments[] }`. HTML is stripped to text when there's no plain part; very large bodies are truncated (`body_truncated`). - `gmail_get_thread` — `{ account_id, thread_id }` → every message in the conversation, parsed. - `gmail_get_attachment` — `{ account_id, message_id, attachment_id }` → `{ size, data_base64url }`. - `gmail_send` — `{ account_id, to, subject, body, cc?, bcc?, html?, thread_id? }` → sends immediately. **NOT reversible — confirm recipient + content with the user first.** `thread_id` sends as a reply in an existing conversation. - `gmail_create_draft` — same shape as `gmail_send` minus `thread_id` → saves a draft WITHOUT sending. The safe default when unsure. - `gmail_list_drafts` / `gmail_send_draft` (`{ account_id, draft_id }`, NOT reversible) / `gmail_delete_draft`. - `gmail_modify_labels` — `{ account_id, message_id, add[], remove[] }` (label ids). Archive = remove `INBOX`; mark read = remove `UNREAD`; star = add `STARRED`; spam = add `SPAM`. - `gmail_trash_message` / `gmail_untrash_message` — Trash is recoverable for 30 days; prefer these. - `gmail_delete_message` — **PERMANENT, bypasses Trash, cannot be undone.** Only on explicit user request; otherwise use `gmail_trash_message`. - `gmail_list_labels` / `gmail_create_label` (`{ account_id, name }`) / `gmail_delete_label`. - `gmail_get_vacation` / `gmail_set_vacation` — `{ account_id, enable, subject?, body?, restrict_to_contacts?, start_time?, end_time? }` (times are epoch-ms strings). Confirm before enabling — it auto-replies to incoming mail. - `gmail_list_filters` / `gmail_create_filter` (`{ account_id, criteria, action }`) / `gmail_delete_filter`. - `gmail_list_history` — `{ account_id, start_history_id, max? }` → mailbox changes since a history id (incremental sync). ### Microsoft Teams tools (`teams` scope) Read, search and send Teams **chat** messages as the connected user. Accounts are **personal** — there is no sharing surface. The connect flow lives in the dashboard at `https://lynk.run/teams`. `account_id` is **optional on every tool**: with exactly one account connected it resolves automatically; with several, the error names them so you can pick. **Why there is no bot identity.** Microsoft Graph supports sending a Teams message only with a *delegated* permission — the one application permission that can post (`Teamwork.Migrate.All`) is for data migration into a chat in migration mode. So every message goes out under the user's own name, and the integration is per-user by construction. The scopes are least-privilege (`Chat.Read` + `ChatMessage.Send`) and include **no** `.All` permission. - `teams_list_accounts` — `{}` → `{ count, accounts: [{ id, upn, display_name, healthy, needs_reconnect, connected_at, instance_context, instance_dont }] }`. If `needs_reconnect` is true the user must reconnect in the dashboard (Microsoft rotates refresh tokens, and an admin revoking consent kills them outright). - `teams_list_chats` — `{ account_id?, top?, next_link? }` → `{ chats: [{ id, chat_type, topic, title, members, last_updated_at, web_url }], next_link }`, most recently active first. `title` is the group topic or — for a 1:1, which has no topic — the other participant's name. `top` max 50 (Graph's own ceiling). - `teams_get_messages` — `{ account_id?, chat_id, top?, next_link? }` → `{ messages: [{ id, from, created_at, body_text, body_truncated, mentions, has_attachments, … }], next_link }`, **newest first** (Graph sorts descending only on this endpoint — reverse for reading order). Join/leave/rename system events and deleted messages are filtered out. Bodies are HTML converted to text and capped (`body_truncated`). - `teams_search_messages` — `{ account_id?, query, size?, from? }` → `{ hits: [{ message_id, chat_id, channel_id, from, summary, created_at, web_url }], more_available }`. Full-text over bodies **and attachment contents**, ranked by relevance. KQL scope terms: `from:bob`, `sent>2026-09-01`, `IsMentioned:true` (what needs the user), `hasAttachment:true`. Each hit is a relevance **snippet**, not the full body — Microsoft's Search API returns a reduced message; read the whole thing via `teams_get_messages` with the hit's `chat_id`. Results cannot be sorted, and there is deliberately **no total count**: the API reports only a per-page count, so any "total" would be fabricated — use `more_available`. - `teams_send_message` — `{ account_id?, chat_id, text?, html? }` → posts into an existing chat. **Delivers immediately and is NOT reversible** (a Teams message cannot be unsent) — confirm the exact text and the recipient first, and respect the account's `instance_dont`. Cannot create a chat; get `chat_id` from `teams_list_chats`. `text` for plain, `html` for a small formatting subset (``, ``, `
`, `
`, `
    `). Max 28 000 characters. - `teams_update_account_config` — `{ account_id?, instance_context?, instance_dont? }` → persists Lynk-side facts/guardrails for the account. Touches no Teams data. **Not in v1:** team channels (they need the tenant-wide `ChannelMessage.Read.All` / `ChannelMessage.Send` scopes — a separate admin-consent decision), and push notifications for new messages (Graph change notifications for chat resources require encrypted content with your own certificate). ### GitHub tools (`github` scope) GitHub Actions **secret + variable management** — plus **repository creation** — via the user's connected Personal Access Tokens. Accounts are **personal** — there is no sharing. Every tool takes an optional `account_id` (from `github_list_accounts`) — omit it when exactly one account is connected. Repos are addressed as `repo: "owner/repo"`; an optional `environment` targets a deployment environment instead of the repository level. The connect flow lives in the dashboard at `https://api.lynk.run/github`. No Lynk tokens charged. - `github_list_accounts` — `{}` → `{ count, accounts: [{ id, name, github_login, token_expires_at, healthy, instance_context, instance_dont, connected_at }] }`. Read `instance_context` (facts) + `instance_dont` (hard guardrails) BEFORE writing secrets. - `github_update_account_config` — `{ account_id?, instance_context?, instance_dont? }` → persist per-account facts/guardrails ("" / null clears; omit keeps). Touches only Lynk config, never GitHub. - `github_list_repos` — `{ account_id?, query? }` → repos the PAT can access (a fine-grained PAT restricted to selected repos returns exactly those; capped ~300). Or skip it and pass a known "owner/repo" directly. - `github_create_repo` — `{ account_id?, name, private?, description?, auto_init?, organization? }` → `{ ok, repo: { id, name, full_name, owner, html_url, clone_url, ssh_url, private, visibility, default_branch, created_at }, note }`. Creates a NEW repository under the PAT's own account, or under `organization`. **`private` defaults to TRUE** — pass `false` only when the user explicitly asked for a public repo, and confirm name + visibility first (there is deliberately no delete tool). `auto_init: true` adds an initial README so the repo has a default branch and clones immediately; leave it false (default) when an existing local history will be pushed. Name: 1–100 chars of letters, digits, `.`, `-`, `_`; description ≤ 350 chars. **Permissions differ from the secrets tools**: a classic PAT needs the `repo` scope, a fine-grained PAT needs "Administration: Read and write" on ALL repositories (a token restricted to selected repos cannot cover a repo that does not exist yet) — a 403 means the connected token lacks that. A 409/422 means the name is already taken on that account/org. - `github_list_environments` — `{ account_id?, repo }` → the repo's deployment environment names (what `environment` expects). - `github_list_secrets` — `{ account_id?, repo, environment? }` → secret NAMES + timestamps only. Values are write-only on GitHub's side — nobody can read them back. Check this before setting to know whether you are about to overwrite. - `github_set_secret` — `{ account_id?, repo, secret_name, value, environment? }` → create-or-update. The gateway fetches the repo/environment public key and seals `value` server-side (libsodium sealed box); Lynk never stores the value and redacts it from the audit log. **Overwriting an existing name destroys the previous value irrecoverably — confirm with the user first.** Names: letters/digits/underscores, no leading digit, no `GITHUB_` prefix; values ≤ 48 KB. Returns `{ created }`. Never echo the value back into chat afterwards. - `github_delete_secret` — `{ account_id?, repo, secret_name, environment? }` → NOT recoverable; workflows referencing it start failing. Confirm first. - `github_list_variables` / `github_set_variable` / `github_delete_variable` — same shapes with `variable_name`; variables are PLAINTEXT config (values returned by list, visible in the GitHub UI) — never put secrets in them. Gotcha: GitHub answers **404 (not 403)** for repos the PAT can't access — a 404 on a repo the user says exists usually means the fine-grained PAT is missing that repo in its repository list or lacks the "Secrets" permission. ### Mailboxes — generic mail tools (`mailbox` scope) ONE tool family for **every** connected mail account — IMAP/SMTP mailboxes, Microsoft 365 (Graph) accounts and Gmail accounts (bridged via an adapter; the `gmail_*` tools keep working in parallel). Accounts are **personal** — there is no sharing. Every tool takes a string `account_id` (from `mailbox_list_accounts`; a UUID for IMAP/M365, a numeric string for bridged Gmail). The connect flow lives in the dashboard at `https://lynk.run/mailboxes`. - `mailbox_list_accounts` — `{}` → `{ count, accounts: [{ id, provider, email, display_name, healthy, needs_reconnect, search_syntax, instance_context, instance_dont, connected_at }] }`. `provider` is `imap_smtp` | `m365` | `gmail`. **Read `search_syntax` before searching** (semantics differ per provider) and `instance_context`/`instance_dont` before sending/deleting. - `mailbox_update_account_config` — `{ account_id, instance_context?, instance_dont? }` → persist per-account facts/guardrails (works for every provider). "" / null clears, omit keeps. - `mailbox_list_folders` — `{ account_id }` → `{ folders: [{ id, name, role }] }`. Returns the FULL folder tree, sub-folders included; `name` is the full path (e.g. `Posteingang/_done`) so nested folders stay distinguishable. `role` marks well-known TOP-LEVEL folders (inbox | sent | drafts | trash | junk | archive) and is `null` for sub-folders — a user's own sub-folder called `spam` is not the junk folder. Pass a folder `id` from here to `mailbox_search` (`folder`) or `mailbox_move_message` (`folder`). Gmail "folders" are labels. - `mailbox_search` — `{ account_id, query?, folder?, unread_only?, max? }` → `{ count, messages: [{ id, subject, from, date, snippet, is_read, is_flagged, has_attachments, folder }] }`. Query is provider-NATIVE: Gmail search-box syntax, Graph `$search` on M365 (where `unread_only` is ignored when a query is set), plain-text keyword on IMAP (no operators). Omit `query` to list the newest messages. IMAP summaries have no `snippet` (empty string). - `mailbox_get_message` — `{ account_id, message_id }` → full parsed message `{ ..., to, cc, body_text, body_truncated, attachments: [{ id, filename, mime_type, size }] }`. HTML bodies are stripped to text when no plain part exists. - `mailbox_get_attachment` — `{ account_id, message_id, attachment_id }` → `{ filename, mime_type, size, data_base64 }` (standard base64, 25 MB cap). - `mailbox_send` — `{ account_id, to, subject, body, cc?, bcc?, html?, attachments? }` → sends immediately. **NOT reversible — confirm recipient + content with the user first** and respect `instance_dont`. On plain IMAP/SMTP accounts a copy is best-effort filed into the Sent folder. `attachments` is an array of `{ filename, mime_type?, content_base64 }` — bytes ride inline as base64, so keep them small. Per-file/total caps are provider-dependent (Microsoft 365: 3 MB; IMAP/SMTP & Gmail: 25 MB), max 10 files; for larger files have the user attach from the dashboard. - `mailbox_create_draft` — same shape (incl. `attachments`) → saves a draft WITHOUT sending (native drafts on M365/Gmail, an APPEND to the Drafts folder on IMAP). The safe default when unsure. - `mailbox_forward_message` — `{ account_id, message_id, to, cc?, bcc?, note?, subject?, attachments?, include_body? }` → `{ ok, id, subject, to, attachments: [{ filename, size }] }`. Forwards an existing message **with its attachments**. **Prefer this over `mailbox_get_attachment` + `mailbox_send` for any pass-on**: the bytes are read from the source message and re-sent entirely inside Lynk, so a large PDF never enters your context (a `get_attachment` on a ~150 KB+ file already exceeds the tool-result size limit). Pick attachments by **filename or attachment id** as listed by `mailbox_get_message`; omit `attachments` to carry ALL of them, pass `[]` for none. `note` goes above the quoted original; `include_body: false` drops the quote. Subject defaults to `Fwd: ` (never double-prefixed). Sends immediately and is **NOT reversible** — confirm the recipient first. The same per-provider caps as `mailbox_send` apply (M365 3 MB, IMAP/SMTP & Gmail 25 MB). - `mailbox_modify` — `{ account_id, message_id, read?, flagged? }` → mark read/unread and/or flag/unflag (IMAP \\Seen/\\Flagged, M365 isRead/flag, Gmail UNREAD/STARRED). - `mailbox_move_message` — `{ account_id, message_id, folder }` → move to a folder id from `mailbox_list_folders`. **The message id may CHANGE after a move** (IMAP + M365) — use the returned `id` for follow-ups. On Gmail this relabels (adds the label, removes INBOX). - `mailbox_trash_message` — `{ account_id, message_id }` → move to Trash/Deleted Items. On IMAP servers WITHOUT a trash folder the message is expunged instead — treat as destructive and confirm first. ### Calendar (`calendar` scope) Calendar is a **capability of an already-connected mail account**, not a second connection — the same account id, the same encrypted refresh token, the same personal (never shared) scoping as `mailbox`. Only **Microsoft 365** and **Google (Gmail)** accounts have one; IMAP/SMTP mailboxes do not. **Always start with `calendar_list_accounts`.** An account only works here once the user has re-consented with calendar scopes — the listing tells you which have (`calendar_enabled: true`) and gives a `connect_url` for the ones that have not. Calling another tool on an account without the grant returns a clear `calendar_not_granted` error with that same URL; send the user there rather than guessing. - `calendar_list_accounts` — `{}` → `{ count, accounts: [{ id, provider, email, display_name, calendar_enabled, needs_reconnect, connect_url }] }`. `provider` is `m365` | `gmail`. **`account_id` is OPTIONAL on every tool below** — omit it to act on your single calendar-enabled account; with several connected it errors listing them so you pick. - `calendar_list_calendars` — `{ account_id? }` → `{ calendars: [{ id, name, is_primary, can_edit }] }`. Pass a non-primary `calendar_id` to the event tools to work in that calendar; omit it for the primary one. `can_edit: false` calendars (subscribed holiday feeds, shared read-only calendars) reject writes. - `calendar_list_events` — `{ account_id?, from?, to?, calendar_id?, query?, limit? }` → `{ window: { from, to }, count, events: [...] }`. `from`/`to` are ISO timestamps; the default window is now → +7 days, the maximum span is 370 days. **Recurring series are expanded into their individual occurrences** inside the window (M365 `calendarView`, Google `singleEvents`) — so a weekly stand-up appears once per week, not once. Sorted by start time. - `calendar_get_event` — `{ account_id?, event_id, calendar_id? }` → the single event with its full body + attendee list. - `calendar_create_event` — `{ account_id?, subject, start, end, timezone?, all_day?, calendar_id?, body?, location?, attendees?, online_meeting? }` → the created event. **`attendees` sends real invitations the moment this returns** — confirm the list with the user first. `online_meeting: true` asks the provider to attach a real meeting (Teams / Google Meet) and returns its `join_url`. - `calendar_update_event` — `{ account_id?, event_id, calendar_id?, ...same fields }` → "omit = keep" per field. **`attendees` REPLACES the entire list** — anyone left out is uninvited and everyone listed is notified; read the event first and send the full intended list. Updates notify attendees. - `calendar_delete_event` — `{ account_id?, event_id, calendar_id? }` → **cancels the meeting for every attendee and is not reversible.** Confirm explicitly before calling. **Event shape.** Each event carries `id`, `subject`, `start`, `end`, `all_day`, `timezone`, `location`, `organizer`, `attendees[]` (with `response`: accepted | declined | tentative | needs_action), `status`, `is_recurring` / `is_occurrence`, `body_text` (+ `body_truncated`), `web_url`, and the meeting-link fields below. **`start`/`end` are deliberately two different shapes.** A timed event is a full ISO timestamp with an offset. An **all-day event is a bare `YYYY-MM-DD`** — it is NOT given a fake midnight time, because that silently shifts the date across a timezone boundary. Render/compare it as a date, never parse it as an instant. **`join_url` + `join_source` + `conference` — the meeting-bot fields.** `join_source: 'native'` means the platform published the link as structured data (Teams `onlineMeeting`, Google `conferenceData` / `hangoutLink`) — trust it. `join_source: 'body'` means Lynk extracted it from the invitation text or the location field, which is how most Zoom/Webex/Meet invitations sent by an external organiser arrive — it is **best-effort**: say so if you act on it. `conference` names the platform (`teams`, `google_meet`, `zoom`, `webex`, `gotomeeting`, `bluejeans`, `whereby`, `jitsi`, `ringcentral`). Microsoft Defender **Safe Links** wrappers are unwrapped before matching, so a rewritten `*.safelinks.protection.outlook.com/?url=...` invitation still yields the real join URL. ### Cloud Browser — remote browser (`browser` scope) A real Chromium runs server-side, **personal** — isolated one container per USER (a Lynk user can never see another's browser; holds your logged-in cookies). You drive it via the tools below; the user can **watch live and take over** in the dashboard at `https://lynk.run/browser`. Use it for websites with no API (developer/admin portals, forms behind a login). - `browser_create_session` — `{ profile_id?, fresh?, egress? }` → `{ id, status, control_mode, egress, view_url, adopted?, ... }`. Starts a session (= one isolated browser context). **ASYNC:** returns INSTANTLY with `status: "starting"` — the browser cold-starts in the background (~20-40s). **Poll `browser_list_sessions` until the session's `status` is `"active"` before navigating/clicking** — drive tools refuse on a starting session with a "poll until active" error. **Do NOT call create again if it seems slow or errors** — a retry within the start window returns the SAME session (idempotent), so it never double-charges or spawns a duplicate; if you got an error but no id, call `browser_list_sessions` to find the session already coming up. A failed cold start marks the session `error` and auto-refunds the tokens. Costs `BROWSER_TOKENS_PER_SESSION` (5) tokens; unlimited-plan + admin free. `profile_id` resumes a specific saved login; **omit it to use the user's default profile** (auto-created on first use, so logins persist across sessions and resume on the profile's last URL); pass `fresh: true` for a one-off session with NO saved login (overrides `profile_id`). **ONE LIVE SESSION PER PROFILE:** if a session is already live on the target profile, create ADOPTS it (returns `adopted: true`, no new session, no charge) instead of starting a second — two sessions on one profile clobber each other's saved login (the cause of "Trust this browser" being lost minutes after login). To share a session with another agent, both create on the same profile (the 2nd adopts) and each work in your OWN tab (`browser_new_tab` → pass its `target_id` to drive calls); for a separate 2nd browser use `fresh: true` or a different `profile_id`. Cookies persist **encrypted** at rest per profile. **`egress`** picks the exit IP: `'datacenter'` (default, fastest — the Hetzner host directly), `'residential'` (the user's home/office ISP IP) or `'mobile'` (a 4G-carrier IP) — the last two route this session's page traffic through the user's **Lynk Edge Node** (a Mini-PC at home over a WireGuard tunnel). Use a non-datacenter egress ONLY when the target site blocks/flags datacenter IPs (it's slower + metered); if the chosen edge egress isn't configured the call errors, so fall back to `datacenter`. Pass `restore_tabs: false` to keep the saved login but NOT reopen the profile's previously-open tabs (start with one blank tab) — the create response's **`restored_tabs`** array lists exactly which tab URLs are being reopened, so you know which tabs are restored vs. new. Pass `logging: true` to turn on **continuous logging** for the session's whole lifetime — captures ALL console messages, exceptions and network requests (metadata only, no response bodies) into a buffer you read later with `browser_get_console` WITHOUT a reload (24h TTL, ring-capped). Default off. `relaxed` (Chromium flags that keep SSO login iframes + device-trust cookies working) is **ON by default** — leave it unset; pass `relaxed: false` only if you need a strict container (third-party cookies partitioned). Pass `screen_capture: true` to run in a **separate container whose Chromium flags auto-grant `getDisplayMedia`/`getUserMedia`** so a page can record the screen WITHOUT the native source-picker (which is unreachable in a server browser) — use it ONLY when the page needs screen capture (e.g. a Testify recorder session); same cost. Default off. **`dialog_policy`** decides how native JS dialogs (`window.confirm`/`alert`/`prompt`) are answered: `'manual'` (DEFAULT — the dialog is surfaced and you answer it with `browser_handle_dialog`; the human can also resolve it in the live view) | `'accept'` (auto-accept every dialog) | `'dismiss'` (auto-dismiss every dialog). Leave it unset unless you specifically want unattended auto-answering; `beforeunload` prompts are always auto-accepted regardless. The DTO also carries **`active_target_id`** — the tab your drive calls act on AND the tab the live view opens on (one shared pointer; `browser_list_tabs` resolves it to a url/title). **`view_url`** (`https://api.lynk.run/browser/?session=`) deep-links the user straight to this session's live view + takeover, landing them on that same tab — **always send it when you ask the user to take over** (without it the portal won't auto-select an MCP-created session, so the user sees "No active sessions" even though it's live). Also returned by `browser_list_sessions`. - `browser_list_sessions` — `{}` → your active sessions. - **`session_id` is OPTIONAL on every session tool below** — omit it to act on your SINGLE active session; if you have several open it errors with the list so you pick the right one (this stops the "which session is mine?" confusion). Pass it explicitly whenever more than one session is open. - `browser_navigate` — `{ session_id?, url }` → go to an http(s) URL (the active tab). - `browser_get_dom` — `{ session_id }` → the active tab's visible text. Prefer this over screenshots for reading the page before acting. Counts as "observing" (clears the hybrid re-check gate). - `browser_get_console` — `{ session_id, reload?, duration_ms? }` → diagnose WHY a page failed to load. Reloads (default) and watches ~8s, returning `{ console, exceptions, log, network_failures }` where each network failure has the exact `{ url, error (e.g. net::ERR_BLOCKED_BY_RESPONSE), blocked_reason, status }` — INCLUDING failures inside cross-origin iframes (where portal blades load). Use it when a page is blank / shows a generic "Network error — your browser refused the connection" so you can name the blocked request instead of guessing. `reload=false` watches the current page without reloading; `duration_ms` 1000–30000 (default 8000). Read-only, but `reload=true` re-runs the page. Also "observes". **If the session was created with `logging:true`**, this instead returns the buffered continuous log captured over the whole session (`{ logging:true, total, logs:[...] }` — every console/exception/request/response/failure, oldest-first; `reload`/`duration_ms` ignored), so a failure that already happened is there without a reload. - `browser_handle_dialog` — `{ session_id?, accept, prompt_text? }` → answer a native JS dialog (`window.confirm`/`alert`/`prompt`) that is BLOCKING the page. When a page opens one the renderer FREEZES until it's answered, so your other tools (screenshot/click/navigate) return `"a native browser dialog is open (…)"` until you call this. `accept=true` clicks OK/Confirm (e.g. to proceed with a NetSuite "Install Bundle?" confirm), `accept=false` clicks Cancel/Dismiss; for a `prompt()` dialog pass `prompt_text` with `accept=true` to fill its input. The dialog's message is in that "dialog open" error AND on `browser_list_sessions` (an `open_dialog` field on the blocked session). For a DESTRUCTIVE confirm (delete/pay/revoke) do NOT accept autonomously — hand off to the human with the session's `view_url`. `beforeunload` ("leave this page?") prompts are auto-accepted server-side and never surface here. Returns `{ ok, type, accepted }`. - `browser_screenshot` — `{ session_id }` → JPEG (base64) of the active tab. Also "observes". **When the session has more than one tab the result names the tab** (`Active tab 2/4: — <url>`) — use it: tell the user WHICH tab you're in when you hand over, since the `view_url` live view opens on exactly this tab. On a shared/many-tab profile the active tab is pinned + self-healing, so background tabs reloading/timing out never swap it; but if the tab you were driving was CLOSED, the shot is a different tab and the result carries a "⚠️ the tab you were driving was closed — this is a DIFFERENT tab" note → `browser_list_tabs`/`browser_switch_tab` to the right one. A "tab busy / timed out" error means the tab's renderer is pinned or still loading — wait ~2-3s and retry, or switch to a responsive tab. - `browser_scroll` — `{ session_id, direction?: 'down'|'up'|'top'|'bottom', amount? }` → scroll the active tab to reveal off-screen content (down/up by ~one viewport or `amount` CSS px; top/bottom jump to the extremes). Returns `{ scroll_y, max_scroll_y, at_bottom }` so you know whether more content remains. Follow with `browser_screenshot`/`browser_get_dom` to see what's now in view. - `browser_click` — `{ session_id, x, y }` → click at CSS-pixel coordinates of the active tab. The click is always DISPATCHED even if the page is mid-operation (e.g. a heavy save it just started). A `page_busy: true` result means the click WAS sent + queued but the page didn't confirm in time — WAIT ~2-3s then `browser_screenshot`/`browser_get_dom` to verify; do NOT assume the page is broken, navigate away, or re-click blindly. A `tab_changed: true` result means the tab you had pinned was CLOSED, so this click landed on a DIFFERENT tab at those coordinates (it may have hit the wrong thing) — `browser_screenshot` to see where you are, then `browser_list_tabs`/`browser_switch_tab` if needed. (`browser_type`/`browser_press_key` behave identically.) - `browser_drag` — `{ session_id, from_x, from_y, to_x, to_y, steps? }` → click-and-drag (press at `from` → move to `to` → release). The path for what a click can't do: dragging a resize handle to resize a panel/column, moving a range slider, reordering a list, or HTML5 drag-and-drop. `steps` (default 12) is how many intermediate move events the motion path uses — raise it for DnD libraries that need a real path. Coordinates are CSS-pixels (same space as `browser_click`); if a viewport/device is emulated they map to that emulated size. - `browser_type` — `{ session_id, text }` → type into the focused element (click the field first). - `browser_select_option` — `{ session_id, selector, label?, value? }` → set a native `<select>` dropdown via the DOM. **USE THIS for `<select>`s instead of clicking** — a native select opens its option list in browser chrome OUTSIDE the screencast, so coordinate-clicks (and even human takeover) can't reach the popup. Match by visible `label` (case-insensitive, exact preferred then contains) or `value`. On a miss it returns the available options so you can retry with an exact label. Find the selector with `browser_get_dom`. - `browser_press_key` — `{ session_id, key, modifiers?, times? }` → press a key the typing tool can't send: `Enter` (submit), `Tab` (next field), `ArrowUp`/`ArrowDown` (move within a focused select), Escape, etc. `modifiers` is a CDP bitmask (Alt=1, Ctrl=2, Meta=4, Shift=8). Focus the target first. - `browser_long_press` — `{ session_id, x, y, duration_ms? }` → press and HOLD at (x,y) then release. For buttons that need a deliberate long-press (hold-to-confirm, touch-and-hold menus) that a normal instant `browser_click` can't trigger. `duration_ms` is the hold time (default 800, 50–10000). The mode auto-selects from the emulated device: a mobile-emulated session (set one with `browser_set_viewport` preset=mobile/tablet) sends a real TOUCH hold so `touchstart`/`touchend` long-press handlers fire; otherwise a mouse hold. Returns `{ ok, duration_ms, mode: 'touch'|'mouse' }`. - `browser_set_viewport` — `{ session_id, preset?: 'desktop'|'xl'|'tablet'|'mobile', width?, height?, device_scale_factor?, mobile?, reset? }` → resize the viewport and optionally emulate a mobile/tablet device (touch + mobile user-agent) so you can see and test a page's responsive layout. Pass a `preset` OR custom `width`+`height` (+ `mobile=true` for touch + mobile UA); `reset=true` returns to native. The setting **sticks** for the session — every later `browser_screenshot`/`browser_click`/`browser_drag`/`browser_get_dom` uses it and coordinates map to the emulated size, so set it once then screenshot. Presets: desktop 1280×800, xl 1600×900, tablet 820×1180 (touch), mobile 390×844 (touch + mobile UA). - `browser_run_actions` — `{ session_id?, steps[], delay_ms? }` → run a SEQUENCE of steps in ONE call (over a single browser connection) instead of many separate click/type round-trips. **This is the efficient way to fill a multi-field form** (or several): read the page once with `browser_get_dom` for selectors, then send all steps. Each step is `{ action, ... }`: `fill {selector,text}` (set an input/textarea by CSS selector — the coordinate-free form-fill primitive; fires input/change so React/Vue/Angular update; reaches same-origin iframes), `click {selector}` (DOM click) or `click {x,y}` (coordinate fallback for canvas/custom widgets), `type {text}` (into the focused element), `press_key {key,modifiers?,times?}`, `select_option {selector,label?|value?}`, `scroll {direction?,amount?}`, `navigate {url}`, `wait {ms}` (explicit pause, e.g. after a navigate), `fill_credentials {label?,...}` (server-side wallet fill — you never see the password), `fill_credential_field {field,label?,selector?}` (targeted single-value fill — one saved username/password into a selector'd or focused field; batch `click`→`fill_credential_field`→`press_key Enter` for a two-step login). A short pause (`delay_ms`, default 250, clamp 0–5000) is inserted after each step; a `wait` step uses its own ms. **Stops at the first failing step** → `{ ok, total, completed, failed_at, results:[{index,action,ok,error?,detail?}] }`, so you see exactly where it stopped and resume from there. Max 50 steps/call; same control-mode gate as the single tools. - `browser_list_tabs` — `{ session_id? }` → list the session's open tabs (pages): each with `{ target_id, url, title, active }`. The `active` tab is the one navigate/click/type/screenshot act on. Use it whenever a click opened a popup/new tab, or you're unsure which tab is focused. - `browser_switch_tab` — `{ session_id?, target_id }` → make a tab the active one (and bring it to front) so subsequent navigate/click/type/screenshot act on it. The choice STICKS for the session until you switch again or the tab closes. `target_id` comes from `browser_list_tabs`. - `browser_new_tab` — `{ session_id?, url? }` → open a new tab (optionally at an http(s) `url`) and make it active. Returns the new tab's `target_id`. Use it to work on a second page without losing the first. - `browser_close_tab` — `{ session_id?, target_id }` → close a tab. Refuses to close the last remaining tab; if you close the active tab the next tool falls back to another open tab. - `browser_fill_credentials` — `{ session_id, label?, username_selector?, password_selector? }` → fill a login form on the session's CURRENT page from the user's saved **password wallet**, server-side. **You never see the password** — it's decrypted and typed into the page by the gateway; you only get back `{ ok, label, domain, filled }`. Use this to get past a login wall: navigate to the login page, call this, then submit (`browser_click` the sign-in button or `browser_press_key Enter`). The matching credential is chosen by the page's domain; pass `label` to disambiguate when several logins exist for one site. **Reaches login forms inside iframes** — including cross-origin auth widgets on the **same root domain** (e.g. Apple Developer's `idmsa.apple.com` form embedded in `developer.apple.com`); a form on a DIFFERENT root domain (cross-domain SSO like `login.microsoftonline.com` inside an Azure portal page) is deliberately NOT filled — hand off to the user via takeover for those. If no saved credential matches, it errors — save one with `browser_save_credentials` (if the user gives you the password) or ask them to add it in the dashboard. - `browser_fill_credential_field` — `{ session_id?, field, label?, selector? }` → the MANUAL, targeted fill: insert ONLY the username, the password, or a freshly generated 2FA code (`field`: `username` | `password` | `totp`) from a saved credential into ONE field — still server-side, you never see the value (returns `{ ok, field, label, domain, filled, target }`). Use this when `browser_fill_credentials` can't locate the field: **multi-step logins where the password is on a SEPARATE page (Apple ID / App Store Connect — email first, password next), custom / shadow-DOM password widgets, or unusual markup.** Target the field EITHER by CSS `selector` OR by omitting it and clicking the field first (`browser_click`) so it's focused. Typical Apple flow: `browser_click` the password box → `browser_fill_credential_field { field:"password", label:"Apple" }` → `browser_press_key Enter`. Same security frame-gate as `browser_fill_credentials` (fills same-root-domain iframes like `idmsa.apple.com`, never an unrelated cross-domain frame). Only a visible input/textarea is written. - `browser_set_cookies` — `{ session_id, cookies, domain?, reload? }` → inject cookies into the session so a page is logged in WITHOUT signing in through the form (the path for sites where importing a session cookie is the only/fastest way in). The cookies are written into Chromium server-side and persist with the session's login profile, so a later session on the same profile resumes signed in. `cookies` is EITHER an array of cookie objects (the Cookie-Editor / EditThisCookie browser-extension export: `{ name, value, domain?, path?, secure?, httpOnly?, sameSite?, expirationDate? }`) OR a raw `"name=value; name2=value2"` header string. `domain` is the target host (required for the raw-header form, fallback for array entries without a domain). Reloads the page by default (`reload=false` to skip). Returns `{ ok, set, skipped, domains, reloaded }` — never the cookie values. **Privacy:** the cookie VALUES travel through YOUR context (like saving a credential, unlike a wallet fill) — only do this when the user explicitly hands you cookies to import; the zero-exposure alternative is for them to paste them in the dashboard (Cloud Browser → live view → "Inject cookies"). - `browser_clear_site_data` — `{ session_id?, origins, clear_cache?, reload? }` → factory-reset the listed origins INSIDE the session's browser: localStorage, sessionStorage, IndexedDB, service workers, caches AND those origins' cookies (plus the shared HTTP cache unless `clear_cache=false`), while every OTHER site's login survives. The repair for a heavy SPA stuck broken across sessions because the saved profile keeps restoring poisoned state — canonical case: the Azure/Entra portal permanently showing "Netzwerkfehler"/"Error displaying your content" because it is pinned to a bundle version its CDN no longer serves. `origins` takes bare hosts or https URLs (max 10). Sign-in usually recovers silently, since the identity provider lives on its own origin (e.g. `login.microsoftonline.com`) which is untouched unless you list it. Navigate the tab to a NEUTRAL page first — clearing mid-auth-redirect corrupts in-flight state — and only clear what the user wants reset: the wipe becomes permanent for the profile at the next snapshot. Reloads the active tab by default (`reload=false` to skip). Returns `{ ok, origins, skipped, cache_cleared, reloaded }` — never a stored value. - `browser_inspect_storage` — `{ session_id?, profile_id?, domain?, tokens_only?, reveal? }` → debug a login problem ("the site keeps signing me out", "why is the 2FA prompt back?", "which account is this tab signed in as?") by reading what the browser actually holds: the FULL cookie jar incl. httpOnly cookies, localStorage + sessionStorage per origin, IndexedDB database names — and every token in those values. JWTs are DECODED (issuer, subject, audience, scopes, client id, issued/expires, remaining lifetime, all claims); opaque session/auth tokens are flagged with their expiry and where it came from (`expiry_source`: `jwt` | `json` | `cookie`). For a live session each entry also carries `profile` = `same` | `changed` (a rotating token) | `not_saved` (set after the last snapshot — the next ~2-min checkpoint or releasing the session saves it), plus `saved_only` (saved but gone from the live browser: deleted by the site, expired, or failed to restore). **Read `notes` first** — it states the findings in plain language and names the login-related items. **Redacted by default**: you get lengths, a fingerprint (same value → same fingerprint, so compare two calls to see a token rotate) and decoded claims (the decoded view never carries a JWT signature) — no raw cookie or token value. **`reveal: true`** (boolean or `"true"`) adds the RAW values — cookie values, storage values and each full token, a JWT's signature included — for the caller's own sessions and profiles only. Use it only when the task needs the actual value (the user asks for it, or a cookie/token has to be handed over); a session cookie or token IS the login, so never paste a revealed value anywhere the user didn't ask for. Revealed values are capped at 64,000 characters per call (`BROWSER_INSPECT_AGENT_MAX_REVEAL_CHARS`), spent tokens first (expired / soonest-expiring first), then login (`auth`) cookies and storage keys, then the rest; an entry past the cap keeps its length, fingerprint and claims and is marked `value_omitted: true`, and `notes` says how many were left out — combine reveal with a narrow `domain`. A revealed answer's first note is a warning; every reveal is audit-logged server-side (who, which session/profile, `actor=agent` — never a value). The dashboard "Storage & tokens" panel (the response's `view_url`) shows every value uncapped and keeps it out of the conversation. Pass `domain` (e.g. `"netsuite.com"`) — strongly recommended, a real jar has hundreds of cookies; `tokens_only=true` returns just summary + tokens + diff + notes (the lightest call). Pass `profile_id` instead to inspect a SAVED profile: what the next session on it will start with (no session needed). Read-only and not blocked by the control mode; with a native dialog open only cookies are read. - `browser_upload_file` — `{ session_id?, selector, file_name?, bucket?, object_key?, source_url?, content_base64? }` → put a file into an `<input type=file>` (or a dropzone that owns one) on the active tab — the ONLY way to upload a cert/key/logo/CSV/screenshot (coordinate clicks + typing can't fill a file input). Bytes come from exactly ONE source: a Lynk bucket object (`bucket`+`object_key`), a `source_url` (server-fetched, SSRF-guarded, no redirects), or inline `content_base64`. Point `selector` at the file input OR the visible dropzone (the hidden input inside/associated with it is found automatically). Chromium fires the input's real `input`/`change` events so React/Vue dropzones register the selection. Max 10 MB. Returns `{ ok, used_selector, file_name, size }`. - `browser_download_file` — `{ session_id?, selector? | x+y | url, bucket?, object_key?, share?, timeout_ms? }` → trigger a download on the active tab and store it in the user's Lynk storage (then read it with `bucket_get_object`, hand the user the `/dl/` link, or re-feed it into `browser_upload_file` on another portal). The path for anything that only comes as a browser download behind a login — a purchased asset pack, a generated key/cert (`.p8`/`.p12`), an exported report/CSV, an invoice PDF. TRIGGER = a `selector` click, an `x`+`y` coordinate click, or a direct `url` (session cookies apply). DESTINATION = `bucket` (created if new; `object_key` defaults to the site's filename) and/or `share=true` for a public `https://lynk.run/dl/…` link; omit `bucket` to store it only as a shared file. Streamed to disk (never buffered): max 250 MB by default (operator env `BROWSER_DOWNLOAD_MAX_BYTES`, hard ceiling 1 GB); bucket objects over 10 MB count against a 2 GB total bucket-storage cap, shared files against the shared-file quota. Waits 120 s by default (`timeout_ms` 5000–240000); an oversized download is canceled as soon as the browser reports its size. If the client times out, the file still lands — check `bucket_list_objects` / `files_list` before retrying. Returns `{ ok, bucket?, object_key?, file_name, size, download_url?, share_error? }`. - `browser_fill_totp` — `{ session_id?, label?, selector? }` → type the CURRENT 2FA (authenticator/TOTP) code for a saved login into the one-time-code field on the session's current page. **You never see the code** — it's generated from the stored seed and typed server-side; you get back `{ ok, label, domain, mode, boxes, expires_in_ms }`. Use it when a login lands on "enter your 6-digit code" and `browser_list_credentials` shows `has_totp: true` for that site. It finds the field itself (an `autocomplete="one-time-code"` input, a code-ish named input, or a row of single-character boxes, which it fills with per-digit keystrokes so the widget's auto-advance runs); pass `selector` only if that misses. **A code lasts ~30s — submit immediately**, ideally in the same call: `browser_run_actions` with `[{action:"fill_totp"},{action:"press_key",key:"Enter"}]`. If under 3s of validity remain the server waits for the next code first, so the call can take a moment. No seed saved → it says so; the user can add one in the dashboard, or you can enroll it with `browser_scan_totp_qr`. - `browser_scan_totp_qr` — `{ session_id?, label? }` → read the authenticator QR shown on the session's current page and store its secret on a saved wallet entry, so future logins can be finished with `browser_fill_totp`. This is how you turn 2FA ON autonomously: sign in → open the site's security/2FA settings → start "add authenticator app" → make sure the QR is VISIBLE → call this → confirm the site's verification step with `browser_fill_totp`. **Prefer this over reading the "can't scan the QR?" secret as text** — the QR is decoded from a screenshot inside the gateway, so the seed never passes through your context. Replaces an existing seed if the entry already had one (`replaced: true`) — only do that for a deliberate re-enrollment. Returns `{ ok, label, domain, issuer, account, replaced }`, never the secret. Afterwards tell the user to open Cloud Browser → Passwords and add the account to their phone from the QR shown there. - `browser_save_credentials` — `{ label, domain, username?, password, totp? }` → save a login to the wallet for later filling. Stored encrypted at rest. **Privacy:** saving here passes the password through YOUR context (unlike filling) — only do it when the user explicitly gives you the password to store, CONFIRM the values first, and tell them the dashboard (Cloud Browser → Passwords) is the zero-exposure alternative. Optional `totp` stores a 2FA seed (an `otpauth://` URI or bare base32 secret) — same privacy note, and `browser_scan_totp_qr` is the zero-exposure way to enroll one. Returns the entry without the password. - `browser_list_credentials` — `{}` → labels + sites + usernames of saved logins (NEVER passwords). Use to find a `label` for fill/delete. - `browser_delete_credentials` — `{ label }` → remove a saved login by its wallet label (or id). Confirm with the user first. - `browser_release_session` — `{ session_id }` → stop the session; the login profile (if any) is snapshotted encrypted for a later resume. **Control modes** (`control_mode`, user-switchable live in the dashboard): `agent` (you drive), `hybrid` (you + human share — write tools are refused if a human acted since your last observation; call `browser_screenshot`/`browser_get_dom` to re-check), `human` (human drives — your write tools are refused; reads still work). **Rules:** (1) NEVER click destructive/irreversible actions (delete, pay, revoke, submit) autonomously — navigate there, then ask the user to take over (send them the session's `view_url`) and confirm. (2) For logins, prefer `browser_fill_credentials` (fills a saved login server-side — you never see the password), then submit; if there's no saved credential, either save one with `browser_save_credentials` (only if the user gives you the password — confirm first; it then passes through your context, so mention the dashboard as the zero-exposure path) or hand off to the human via takeover (send the `view_url`). Never type a raw password directly into the page. For a 2FA step: if the wallet entry has a seed (`has_totp`), use `browser_fill_totp`; otherwise read an emailed code from the user's connected Gmail/mailbox and type it, or hand off. Push-approval MFA and hardware keys always need a hand-off. **There is no tool that reveals a stored 2FA seed** — that is dashboard-only, by design. If a login keeps getting lost between sessions, call `browser_inspect_storage` with the site's `domain` (and `tokens_only=true`) before guessing — it shows which login cookies are expired, not saved yet, or gone from the profile. Diagnosing needs no raw value; pass `reveal: true` only when the user needs the actual value (e.g. a cookie to hand to a script). (3) Tabs/popups (OAuth/consent windows) open inside the same session and the live view follows them automatically. **You and the human share ONE active tab**: the live view opens on the tab you're driving (so `view_url` is a true hand-over — say which tab it is), and if the human switches tabs during a takeover, that becomes YOUR active tab too — so after any takeover, `browser_screenshot`/`browser_list_tabs` before acting. (4) Release sessions when done. All sessions/profiles are personal (scoped to you). ### WordPress tools (`wordpress` scope) Content management for connected WordPress sites via the WordPress REST API (Application Passwords). Workspace-scoped — every site is reachable across the user's Lynk workspaces. Read + write. - `wordpress_list_instances` — `{}` → every connected site (`id`, `name`, `base_url`, `last_test_ok`, `instance_context`, `instance_dont`). **Call first** for the `instance_id`; read `instance_context` (site facts) + `instance_dont` (guardrails) before publishing/editing. - `wordpress_update_instance_config` — `{ instance_id, instance_context?, instance_dont? }` → persist per-site AI facts/guardrails (any workspace member). "" / null clears; does NOT touch WordPress content. - `wordpress_list_posts` — `{ instance_id, status?, search?, per_page?, page? }` → `{ total, total_pages, posts[] }` (id, status, slug, link, date, title). `status` ∈ draft/publish/pending/future/private. - `wordpress_get_post` — `{ instance_id, post_id }` → full post incl. raw + rendered title/content/excerpt. - `wordpress_list_pages` — `{ instance_id, status?, search?, per_page?, page? }` → static pages, same shape as posts. - `wordpress_get_page` — `{ instance_id, page_id }` → full page incl. raw + rendered content. - `wordpress_create_post` — `{ instance_id, title, content (HTML), status?, slug?, excerpt?, categories?, tags?, featured_media?, date? }` → created post. **Defaults to `status:"draft"`.** - `wordpress_update_post` — `{ instance_id, post_id, …same fields }` → edit in place (id + URL preserved); every field is "omit = keep". - `wordpress_create_page` — `{ instance_id, title, content (HTML), status?, slug? }` → created static page. **Defaults to `status:"draft"`.** - `wordpress_update_page` — `{ instance_id, page_id, title?, content?, status?, slug? }` → edit a site page in place. This is the tool for changing homepage / landing / product page content. `content` replaces the whole body. - `wordpress_upload_media` — `{ instance_id, filename, mime_type, content_base64 }` → `{ id, source_url, … }`. Use the `id` as `featured_media`, or embed `source_url` in HTML. **Rules:** (1) `content` is HTML — Gutenberg block markup is HTML with `<!-- wp:… -->` comments; write it directly. (2) **NEVER publish or delete without explicit user confirmation** — create leaves content as a draft; only set `status:"publish"` after the user says go live, and overwriting an already-published page changes the live site immediately. (3) Schedule with `status:"future"` + a future ISO `date`. (4) Cite the response `link` (the permalink). (5) **Page-builder sites**: if the site uses WPBakery / Visual Composer, `content.raw` is shortcode markup (`[vc_row][vc_column][vc_column_text]…`) — `wordpress_get_page` first, edit the text INSIDE the shortcode blocks, send the full body back, keep the shortcode skeleton intact. Elementor / Divi store layout in postmeta (not the `content` field) — those page bodies are NOT editable via this API. (6) **WPML/multilingual**: each language is a separate page/post id; editing one does not touch the others. ### NetBox tools (`netbox` scope) Read-only access to connected **NetBox** instances — the DCIM/IPAM source of truth for a network. Workspace-scoped: every instance in any of the user's Lynk workspaces is reachable. The NetBox API token is stored AES-256-GCM encrypted; the underlying client accepts only `GET`/`OPTIONS`, so **this scope has no write path at all**. `instance_id` is OPTIONAL on every tool — omit it when the user has exactly one NetBox connection; with several, the error lists the choices. - `netbox_list_instances` — `{}` → every connected instance (`id`, `name`, `base_url`, `netbox_version`, `last_test_ok`, `instance_context`, `instance_dont`). **Call first.** Read `instance_context` (naming conventions, what each tenant/role means) + `instance_dont` before answering. - `netbox_update_instance_config` — `{ instance_id?, instance_context?, instance_dont? }` → persist per-instance AI facts/guardrails (any workspace member). "" / null clears. Does NOT touch NetBox. - `netbox_search` — `{ instance_id?, q, limit? }` → global search across every object type. **The right first step when you know a name but not what kind of object it is** — a hostname could be a device, a VM, an IP address or a DNS name. Each hit names the matching object type (`object_type`) and carries the projected object. On NetBox 4.1+ this uses the server's own `/api/search/`; on older versions (verified against 4.0.2, where that endpoint 404s) it transparently falls back to a parallel `?q=` fan-out across the common models and the response says which path ran (`via: "search_api" | "fanout"`). Fan-out `count` is the aggregate across the searched models. - `netbox_query` — `{ instance_id?, app, model, filters?, limit?, offset?, detail? }` → the workhorse. `app` ∈ `dcim`, `ipam`, `virtualization`, `circuits`, `tenancy`, `wireless`, `vpn`, `extras`, plus the plugin app `inventory`. Common models: `dcim/devices`, `dcim/sites`, `dcim/racks`, `dcim/interfaces`, `dcim/cables`, `ipam/ip-addresses`, `ipam/prefixes`, `ipam/vlans`, `virtualization/virtual-machines`, `circuits/circuits`, `tenancy/tenants`. `filters` are NetBox's own query params, e.g. `{"site":"ffm14","status":"active"}` or `{"q":"ldap"}`. **Asset management** (`app: "inventory"`, the netbox-inventory plugin — 404s if it is not installed): `inventory/assets` (serial, asset tag, warranty dates, who owns it, where it is stored), `inventory/suppliers`, `inventory/purchases`, `inventory/deliveries`, `inventory/inventory-item-types`. **Who supplied a thing depends on what kind of thing it is** — a CIRCUIT has a `provider` (`circuits/providers`), HARDWARE has a `supplier` (`inventory/suppliers`), and a FACILITY has neither in core NetBox. The two lists are different companies and do not overlap; do not answer a hardware question from the provider list or vice versa. - `netbox_get` — `{ instance_id?, app, model, id, detail? }` → one object, full record by default. - `netbox_schema` — `{ instance_id?, app, model }` → field names, types, choices and valid filters from the API's own `OPTIONS` response. **Call this instead of guessing filter names or enum values.** - `netbox_changelog` — `{ instance_id?, since?, filters?, limit?, offset? }` → NetBox's object change log (who changed what, when). Use for "what changed since yesterday", tracing when a record was last touched, or correlating an incident with a documentation change. Filters: `{"action":"delete"}`, `{"changed_object_type":"dcim.device"}`, `{"user_name":"…"}`. The per-row before/after snapshots are stripped to keep a page readable — use `netbox_get` for detail. **Rules:** (1) **Read-only** — if the user asks for a change, say plainly that this integration cannot write to NetBox rather than implying you will. (2) `detail` defaults to `compact` (useful fields, each relation collapsed to `{id, display}`); `brief` is id/display/name only; `full` is the raw ~60-field object — ask for it only for a single record, a page of full objects is tens of KB. (3) Every result carries a **`web_url`** — cite THAT when pointing a human at a record, never a URL you assembled. (4) The `users`, `core` and `secrets` apps are deliberately unreachable: they expose accounts, permissions, API tokens and stored credentials rather than inventory. Plugin apps are allowed one at a time by name (today: `inventory`), never as a namespace. (5) A **timeout** almost always means a source-IP ACL on the NetBox side, not a bad token — the error message says so. (6) `netbox_schema` reports its fields under `actions.POST`/`actions.PUT` — NetBox exposes the field schema there, never under `GET`. With a strictly read-only token NetBox may omit `actions` entirely; the tool then returns `fields: null` with a note, which is a permission limit, not a missing model. ### Snipe-IT tools (`snipeit` scope) Read-only access to connected **Snipe-IT** instances — an IT asset register (hardware, who has it, license seats, accessories, locations, check-out history). Workspace-scoped: every instance in any of the user's Lynk workspaces is reachable. The Snipe-IT personal API token is stored AES-256-GCM encrypted; the underlying client sends only `GET`, so **this scope has no write path at all**. `instance_id` is OPTIONAL on every tool — omit it when the user has exactly one Snipe-IT connection; with several, the error lists the choices. - `snipeit_list_instances` — `{}` → every connection (`id`, `name`, `base_url`, `api_user` = the Snipe-IT account reads run as, `snipeit_version`, `last_test_ok`, `last_test_error`, `instance_context`, `instance_dont`). **Call first.** - `snipeit_update_instance_config` — `{ instance_id?, instance_context?, instance_dont? }` → persist per-instance AI facts/guardrails (any workspace member). "" / null clears. Does NOT touch Snipe-IT. - `snipeit_find_asset` — `{ instance_id?, asset_tag? | serial? | query?, limit?, detail? }` → exactly one of the three. `asset_tag` = exact match (one asset); `serial` = list (serials are not unique); `query` = free-text hardware search. - `snipeit_list` — `{ instance_id?, resource, search?, filters?, sort?, order?, limit?, offset?, detail? }` → `resource` ∈ `hardware`, `users`, `licenses`, `accessories`, `consumables`, `components`, `locations`, `models`, `categories`, `manufacturers`, `statuslabels`, `companies`, `departments`, `suppliers`, `depreciations`, `fieldsets`. `filters` are Snipe-IT's own list params and take **numeric ids** (`{"status_id":3}`, `{"model_id":12}`, `{"location_id":4}`) — resolve a name with a list call on that resource first. Hardware also accepts `{"status":"RTD"|"Deployed"|"Requestable"|"Undeployable"|"Archived"}`. Default page 50, max 500; continue with `next_offset`. - `snipeit_get` — `{ instance_id?, resource, id, detail? }` → one record. - `snipeit_user_items` — `{ instance_id?, user_id? | email? | username?, detail? }` → hardware + license seats + accessories checked out to that **person**. An ambiguous email/username match returns `candidates` instead of guessing. Items checked out to a location or another asset are not included. - `snipeit_activity` — `{ instance_id?, item_type?, item_id?, target_type?, target_id?, action_type?, limit?, offset? }` → the activity log newest-first (checkouts, checkins, updates, audits, deletions + who did them). `item_type` ∈ asset/accessory/license/consumable/component/user; `target_type` ∈ user/asset/location. **Rules:** (1) **Read-only** — if the user asks for a check-out, check-in or edit, say plainly that this integration cannot write to Snipe-IT. (2) `detail` defaults to `compact`: UI plumbing (`available_actions`, image urls) dropped, relations collapsed to `{id, name}` (+ `username`/`email`/`type` for the assignee), dates flattened, `custom_fields` turned into a `label → value` map, HTML entities decoded (Snipe-IT escapes every string server-side). `full` = the raw object. (3) Every result carries a **`web_url`** — cite THAT. (4) Snipe-IT often answers "not found" with HTTP 200 + `{"status":"error"}`; the client turns that into a real not-found error, so an empty success never masks a miss. (5) A **timeout** means the instance is only reachable from a VPN or allowlisted source IP — the error names the gateway address to allowlist. (6) `settings`, personal-access-token and import/backup endpoints are unreachable by construction (resource allowlist). ### Elastic tools (`elastic` scope) **READ-ONLY.** This integration cannot index, update, delete or reindex anything. Three guards run before a request leaves Lynk, and they hold regardless of what the stored credential is allowed to do: 1. **Endpoint allowlist** — `_bulk`, `_delete_by_query`, `_update_by_query`, `_reindex`, `_update`, `_scroll`, `_security`, `_ilm`, `_snapshot`, `_scripts`, `_ingest` and `_watcher` are unreachable. `POST /<index>/_doc` (which writes) is refused while `GET /<index>/_doc/<id>` (which reads) is allowed. 2. **Index targets** — anything starting with `.` (system/hidden), a bare `*` / `_all`, and cross-cluster `remote:index` targets are refused, on the DSL path **and** inside an ES|QL `FROM`. The security index stores API-key hashes, so a read there is a credential leak rather than a query. 3. **Query body** — `script`, `script_fields`, `runtime_mappings`, `scroll` and `pit` are refused anywhere in the body. Elasticsearch READ privileges alone do **not** stop a search from executing Painless, which is why this guard exists rather than being left to the credential. **The shortest path to an answer:** ``` elastic_list_indices({}) → data-stream / alias names + each timestamp field elastic_field_caps({ pattern: "logs-*" }) → the real field names (do NOT guess them) elastic_esql({ query: "FROM logs-* | WHERE @timestamp >= NOW() - 1 hour | STATS n = COUNT(*) BY log.level" }) ``` - `elastic_list_instances` — `{}` → every connected cluster (`id`, `name`, `es_url`, `es_version`, `es_flavor`, `index_patterns`, `kibana_configured`, `security_enabled`, `credential_can_write`, `instance_context`, `instance_dont`). **Call first.** Read `instance_context` (which index holds what, naming conventions) and `instance_dont` before querying. - `elastic_update_instance_config` — `{ instance_id?, instance_context?, instance_dont? }` → persist per-connection facts/guardrails. `""`/null clears. Does NOT touch the cluster. - `elastic_list_indices` — `{ instance_id?, pattern? }` → indices, aliases and data streams with `timestamp_field` and (where the credential allows) `docs_count`. **Query the data-stream or alias name, never the `.ds-…` backing index.** Built on `_resolve/index`, which needs fewer privileges than `_cat` and works on serverless. - `elastic_field_caps` — `{ instance_id?, pattern }` → `{ name, types, searchable, aggregatable }`. Call this **instead of guessing** — a query against a field that does not exist returns zero hits, which is indistinguishable from "no matching data". - `elastic_esql` — `{ instance_id?, query }` → `{ columns, rows, row_count, truncated }`. **The preferred query tool.** ES|QL is piped (`FROM … | WHERE … | STATS … | SORT … | LIMIT …`) and cannot write by construction — its parser has no write commands. Bound the time range in the query, use `KEEP` to name your columns, and prefer `STATS` over fetching documents. Needs Elasticsearch 8.11+; `elastic_list_instances` says whether the cluster has it. - `elastic_search` — `{ instance_id?, indices, body?, size?, fields?, time_field?, from?, to? }` → projected hits + aggregations. The escape hatch for what ES|QL cannot express and for clusters below 8.11. Pass `from`/`to` rather than writing a range filter — Lynk injects it. `size: 0` + `aggs` aggregates without returning documents. Hits are **projected** (flattened, ECS-default fields, long values truncated), never raw `_source`. **`total.relation: "gte"` means the count is capped — never report it as exact.** - `elastic_get_document` — `{ instance_id?, index, doc_id }` → one document from one concrete index (not a pattern). - `elastic_list_data_views` — `{ instance_id? }` → Kibana's curated index patterns with their time field. The organisation's own answer to "where does this data live". - `elastic_list_saved_objects` — `{ instance_id?, type?, limit? }` → dashboards, saved searches, visualisations. These document what people in this organisation actually look at. The heavy `fields` attribute is stripped. - `elastic_list_alerts` — `{ instance_id?, limit? }` → `{ alerts, sources, total }`. **Read `sources` before concluding anything**: it names every alerting system asked and what each returned, so an empty `alerts` list caused by a missing Kibana URL or missing privileges is never mistaken for "nothing is alerting". - `elastic_cluster_health` — `{ instance_id? }` → status, node and shard counts. Reports `available: false` with a reason (rather than a fabricated green) when the credential lacks `cluster:monitor` or the deployment is serverless. **Kibana is optional and needs its own privileges.** An Elasticsearch-only API key authenticates against Kibana but carries no Kibana feature privileges: `data views` and `saved objects` come back as an **EMPTY LIST rather than an error**, and alerting is refused. So an empty result there may mean missing permissions, not missing data — `elastic_list_instances` and the connection test say which. **PII.** Log and APM documents are the most PII-dense data most organisations hold. Count and aggregate first; fetch raw documents last and only as many as the question needs. ### Grafana tools (`grafana` scope) Read a connected **Grafana**: dashboards (and the queries behind their panels), datasources, folders, metric queries and alerts. Workspace-scoped — an instance is reachable if you are a member of the workspace that owns it. `instance_id` is optional everywhere: omit it when you have exactly one connection, otherwise the error lists the choices. **READ-ONLY** — no tool here can create or change anything in Grafana. **The normal path to a number is three calls, and skipping to the last one does not work:** metric names are not guessable and Grafana exposes no browsable metric catalogue, so the DASHBOARDS are the documentation. ``` grafana_search_dashboards({ query: "power" }) → find the dashboard that already plots it grafana_get_dashboard({ uid }) → read the panel's PromQL / SQL + its datasource uid grafana_query({ datasource, query }) → run it (substituting any $variables) ``` - `grafana_list_instances` — `{}` → every connected instance (`id`, `name`, `base_url`, `grafana_version`, `last_test_ok`, `instance_context`, `instance_dont`). **Call first.** Read `instance_context` (which datasource holds what, what the site/rack naming means) + `instance_dont` before answering. - `grafana_update_instance_config` — `{ instance_id?, instance_context?, instance_dont? }` → persist per-instance AI facts/guardrails (any workspace member). "" / null clears. Does NOT touch Grafana. - `grafana_search_dashboards` — `{ instance_id?, query?, tag?, folder_uid?, limit? }` → `{ uid, title, folder, tags, url }`. Search by SUBJECT ("power", "rack", "latency", "postgres"), not by metric name. - `grafana_get_dashboard` — `{ instance_id?, uid }` → the dashboard **including every panel's `targets`**, i.e. the literal PromQL (`expr`) or SQL (`rawSql`) it runs plus the datasource uid to run it against. Also returns `templating` (the dashboard's `$variables` with their `current` value and a sample of options). Panels inside collapsed rows are included. Styling (`fieldConfig`, `options`) is stripped — it answers no question and runs to tens of KB. - `grafana_list_datasources` — `{ instance_id?, refresh? }` → `{ uid, name, type, kind }`. `kind` decides the query language: `prometheus` → PromQL, `sql` → SELECT-only SQL, `alertmanager` → not directly queryable (it is what `grafana_list_alerts` reads), `other` → not addressable. A dashboard panel already names its datasource uid, so you often do not need this call. - `grafana_query` — `{ instance_id?, datasource, query, kind?, start?, end?, step?, from?, to?, max_rows? }` → rows. PromQL: `kind:'instant'` (default) gives the current value per series with the metric's labels flattened onto each row; `kind:'range'` gives a series per row (`values: [[iso, number], …]`) and takes `start`/`end`/`step`. SQL: takes `from`/`to` for Grafana's `$__timeFilter` macros. Capped at 1000 rows — `truncated: true` means narrow the query, not paginate. - `grafana_list_alerts` — `{ instance_id?, state?, limit? }` → `{ alerts, rules, counts, sources }`. `alerts` are active alert INSTANCES (from Alertmanager), `rules` are alerting RULES and their state (from Prometheus) — one rule can produce many instances, so the counts legitimately differ. `sources` names every system asked and what each returned, so a 0 from one (Grafana's own alerting commonly returns 0 while an external pair reports hundreds) never becomes the whole answer. **`counts` are per SOURCE, not distinct**: two Prometheis sharing a rule file evaluate it independently, so the same rule appears once per source with its own state and `active_count` — quote a figure with its source rather than merging rows by name. - `grafana_list_folders` — `{ instance_id?, limit? }` → `{ uid, title }`, for orienting yourself and for narrowing a dashboard search. **Rules:** (1) **Read-only** — if the user asks for a dashboard or alert-rule change, say plainly that this integration cannot write to Grafana rather than implying you will. (2) **Do not invent metric names.** Get the query from a panel via `grafana_get_dashboard`; a guessed metric silently returns an empty result, which looks like "the value is zero". (3) A panel query containing a `$variable` will NOT run as-is — substitute a concrete value from `templating` first; likewise replace Grafana's `$__range` / `$__timeFilter` macros with a real range when you run a query yourself (`grafana_query` passes `from`/`to` through for SQL). (4) **SQL is SELECT-only and enforced server-side**: a single statement starting with SELECT / WITH / SHOW / DESCRIBE / EXPLAIN, with write verbs rejected wherever they appear (including inside a CTE). Do not attempt a write to "check" something. Always add a LIMIT to an exploratory SELECT. (5) **Alerting may not live in Grafana's own alerting.** Where it runs on an external Prometheus/Alertmanager pair, Grafana's built-in alert API reports ZERO rules while hundreds of alerts are firing — so `grafana_list_alerts` reads the alerting datasources and returns a `sources` list naming every system it asked and what each returned. If one source reports 0, say WHICH one; never turn that into "nothing is alerting". (6) A **timeout** almost always means a source-IP ACL on the Grafana side, not a bad token — the error message says so. ### Testify (scope: `testify`) Set up + read Testify — guided test sessions recorded by external testers on public recording links (screen + voiceover + a tickable test-plan checklist), processed into transcript + storyboard + timeline events. Sessions come in two modes: `recorded` (screen + voice video, the default) and `text` (NO video — the tester only ticks the plan and types per-step findings; the voiceless / headless / AI-tester path). Agents can create a project + a distributable recording link, invite testers by email (`testify_invite_testers`) AND file a text report directly (`testify_submit_text_report`); other report management (delete, settings, revoke) stays in the dashboard. Every response carries a `view_url`. - `testify_list_projects` — `{}` → your test projects across all workspaces (id, name, report_count, view_url) **plus `sdk_key` + a ready-to-paste `sdk_snippet`** (the `<script src="https://lynk.run/sdk/testify.js" data-key="tsp_…" async>` tag). **Call first.** Use `sdk_snippet` to install in-app telemetry + the test-plan overlay into the app under test (staging build) for an EXISTING project — no need to re-create it. - `testify_create_project` — `{ name, handbook_md?, workspace_id? }` → create a project (id + `tsp_…` sdk_key + ready `sdk_snippet`). Omit `workspace_id` for your personal workspace. The `handbook_md` markdown is shown to every tester. - `testify_create_link` — `{ project_id, title?, plan_md?, target_url?, max_uses?, expires_in_days? }` → a distributable recording link `https://lynk.run/rec/<token>` to hand to testers (no account/extension; record in-browser). Numbered `plan_md` lines ("1. …", "2) …") become the tester's checklist; `target_url` adds an "Open test app" button + enables SDK pairing; `max_uses`/`expires_in_days` (0 = never) cap the link. The response's `id` is the internal link id — pass it to `testify_invite_testers`. - `testify_invite_testers` — `{ link_id, emails }` → invite testers to a recording link by email. `link_id` is the link's internal `id` (from `testify_create_link`, NOT the public token). Each address gets a personalised recording URL and an email is **sent automatically** (subject "You're invited to test <project>", with the test plan + a "Start the test →" button). **Idempotent per (link, email)** — an already-invited address is skipped, never re-mailed (use it only to add NEW testers; resend lives in the dashboard). These are real emails to real people — confirm the list with the user first. Returns `{ invites, created, sent, email_configured }`; if `email_configured` is false the invites exist but no mail went out (share the personalised URL manually). - `testify_submit_text_report` — `{ link_token, tester_name?, notes?, steps? }` → file a **TEXT (no-video) report** against a recording link in ONE call — the headless / AI-tester path. `link_token` is the `<token>` in `https://lynk.run/rec/<token>` (the full URL works too). `steps` is `[{ n, passed?, note? }]` — `passed:false` and/or a `note` marks a finding; each `note` becomes a step-linked finding fed to the auto-issue extractor (same as a tester typing it). Returns `{ report_id, status:'processing', mode:'text', view_url }`; issues extract automatically. **You must be a member of the link's workspace** (this authenticated tool is the project-owner side; the public token-only flow is the browser recorder). - `testify_list_reports` — `{ project_id }` → reports newest-first (status, `mode` ('recorded'|'text'), duration_sec, tester_name, checklist outcome summary, has_transcript, event/frame counts). - `testify_get_report` — `{ report_id }` → full metadata: `mode` ('recorded'|'text'), `steps_result` (each plan step with `ticked_at_ms` timestamp or null = skipped), tester notes, storyboard timestamps, `issue_count`. **Start here.** A skipped step is the strongest "something broke here" signal. A `text`-mode report has no video/transcript/storyboard — read `testify_get_issues` + `testify_get_events`. If `issue_count > 0`, call `testify_get_issues` next. - `testify_get_issues` — `{ report_id }` → the issues auto-extracted at processing time (LLM over the tester's voiceover transcript + captured console/network/errors). Each: `title`, `detail`, `severity` (low/medium/high), `ts_ms` (where it surfaced), `frame_ms` (nearest storyboard frame — pass to `testify_get_frame` for the screenshot), and `console` (the console/network events in its time window). **Usually the fastest, most actionable read of a report — prefer it over re-deriving issues from the raw transcript.** - `testify_get_transcript` — `{ report_id }` → the voiceover as timestamped WebVTT. Mute recordings have none (that's not an error). - `testify_get_events` — `{ report_id, kinds?, from_ms?, to_ms? }` → timeline events: `step` (checklist ticks), `issue` (tester's "Mark issue" markers, often with a note); `console`/`network`/`click` once the in-app SDK is installed. Filter to stay focused. An `issue` event may carry an `annotation` object — the tester DREW on the screen (SDK pen tool): `annotation.anchors` names the UI element under each stroke/box/comment (`at`), its viewport bounding box (`box`/`pin`) and a coarse `region` ("top-right", …) — the machine-readable WHERE; in a recorded session the sketch is also baked into the video frame at the event's `ts_ms` (`testify_get_frame`). - `testify_get_storyboard` — `{ report_id, max_frames? }` → scene-change thumbnails (images) captioned with timestamps — the cheap visual overview. - `testify_get_frame` — `{ report_id, ts_ms }` → one full-resolution JPEG at a millisecond offset (extracted on demand, cached). Be frugal: text first, a handful of targeted frames second. All timestamps are milliseconds from recording start and shared across transcript/events/storyboard/frames (one clock). Reports auto-delete 30 days after completion. ### Voice tools (`voice` scope) ElevenLabs text-to-speech + sound effects. **BYOK** — every generation runs against the USER's OWN connected ElevenLabs key (encrypted at rest, per-user). The user pays ElevenLabs directly; **Lynk charges NO tokens**. If no key is connected, every voice tool returns an error telling the user to connect one at `https://api.lynk.run/voice`. Every generation returns a downloadable URL — MCP can't return raw audio bytes. By default the audio is a public `https://lynk.run/dl/<id>` shared file; pass `store_to: { bucket, key }` to write it into your bucket object store at a STABLE address instead (the audio-manifest path: first run uploads, later runs overwrite the same key). Identical requests reuse a per-user cache — a `cached: true` result skips the upstream call (saves your own ElevenLabs quota). - `voice_list_voices` — `{}` → `{ voices: [{ voice_id, name, category?, description?, preview_url?, labels? }], count }` (the caller's own account voices). **Call first** to get a `voice_id`. - `voice_tts` — `{ text, voice_id, model_id?, output_format?, language_code?, voice_settings?, seed?, previous_text?, next_text?, store_to?, filename?, expires_in_days?, no_cache? }` → `{ audio_url | stored:{bucket,key,size}, format, mime, characters, cached }`. `voice_settings` = `{ stability?, similarity_boost?, style?, use_speaker_boost?, speed? }`. Default model `eleven_flash_v2_5`, default format `mp3_44100_128`. `previous_text`/`next_text` give continuity across split clips. - `voice_sound_effect` — `{ text, duration_seconds?, prompt_influence?, output_format?, store_to?, filename?, no_cache? }` → same result shape (no `characters`). `text` is the sound-effect prompt. - `voice_music` — `{ prompt, music_length_ms?, force_instrumental?, model_id?, output_format?, seed?, store_to?, filename?, expires_in_days?, no_cache? }` → same result shape. Composes a music track from a text prompt. `music_length_ms` bounds the length (3000–600000 ms; omit to let the model choose). `force_instrumental` drops vocals; `seed` makes a prompt reproducible. Default model `music_v2`. - `voice_batch` — generate MANY narration clips in ONE call (the **audiobook engine**). `{ items:[{ key, text, voice_id?, model_id?, output_format?, language_code?, voice_settings?, seed? }], defaults?:{ voice_id?, model_id?, output_format?, language_code?, voice_settings?, seed?, bucket?, key_prefix?, expires_in_days? }, stitch?, concurrency?, no_cache? }` → a manifest `{ items:[{ key, ok, characters, cached?, format?, bucket?, object_key?, size?, audio_url?, id?, error? }], total, succeeded, failed, characters_total, key_rejected, stitched }`. Each item's `key` is its logical id AND (with `defaults.bucket`, auto-created) its object key (`key_prefix + key`); without a bucket each clip is a `/dl/` shared file. **CONSISTENCY: set `stitch: true`** to auto-wire each item's `previous_text`/`next_text` from its neighbours so the narrator reads the whole ordered sequence as ONE continuous performance — combine with a single `defaults.voice_id` + locked `defaults.voice_settings` + a fixed `defaults.seed` for a stable narrator. Order matters when stitching. Max 200 items/call, each ≤ 5000 chars — split long text into ordered items yourself first. - `voice_request` — `{ path }` → READ-ONLY escape hatch for ElevenLabs catalog endpoints not wrapped first-class. GET only, whitelisted to `/voices`, `/models`, `/shared-voices`, `/pronunciation-dictionaries` (account-admin endpoints are deliberately unreachable). Runs against your own key. Voice-role mapping (e.g. `benny`→`voice_id`) and audio manifests are the consuming app's job — Lynk's API is a generic voice capability, not app-specific. ### SSH tools (`ssh` scope) Register servers as connections (in the dashboard at `https://api.lynk.run/ssh` or with `ssh_create_connection`), then run shell commands and transfer files over SSH on them. A key can back many hosts; connections are per-connection shareable. Execution is ONE-SHOT — each call opens a fresh connection, runs, and closes, so shell state (working directory, env vars, background processes) does NOT persist across calls. The "session" that carries a back-and-forth diagnosis is THIS MCP conversation, not a remote shell. Every command is audit-logged. **No Lynk tokens are charged** (the user brings their own servers). **SAFETY — you are executing real commands on real servers.** ALWAYS call `ssh_list_connections` FIRST and read each connection's `instance_context` (host facts) + `instance_dont` (hard guardrails you MUST obey). For any DESTRUCTIVE or irreversible action (delete, restart/stop a service, drop data, overwrite files, reboot) CONFIRM with the user before running — never run it autonomously. A `host_key_mismatch` error means the server's host key changed (possible MITM or a rebuild) — do NOT retry; tell the user to verify and reset the pinned key in the dashboard. - `ssh_create_connection` — `{ name, host, username, port?, auth_method?, private_key?|key_id?|password?, passphrase?, key_name? }` → `{ connection, test }`. Register a NEW connection you own (inline `private_key` is vaulted encrypted, or reuse a `key_id`, or `password`); auto-tests + TOFU-pins the host key. Secrets are never returned. Owner-only write — call only when the user asked to register a server. - `ssh_list_connections` — `{}` → `{ connections: [{ id, name, host, port, username, auth_method, is_owner, owner_email, is_readonly, command_allowlist, host_key_pinned, last_test_ok, instance_context, instance_dont }] }`. Use `id` as `connection_id` everywhere else. Read `instance_context` + `instance_dont` before acting. - `ssh_run` — `{ connection_id, command, stdin?, timeout_ms? }` → `{ stdout, stderr, exit_code, signal, truncated, duration_ms }`. One-shot; chain with `&&` or use absolute paths (`cd /var/log && tail -n100 syslog`), `bash -lc "…"` for a login-shell env. Pipe `stdin` for prompts (sudo -S, a DB REPL). `timeout_ms` default 60s, max 10min. stdout/stderr each capped at 1MB (`truncated: true` when hit). Read-only connections reject obviously-destructive commands + anything off their allowlist (best-effort guardrail, not a hard sandbox). - `ssh_upload` — `{ connection_id, remote_path, content_base64 }` → `{ ok, bytes, remote_path }`. SFTP put (≤100MB). Blocked on read-only connections. - `ssh_download` — `{ connection_id, remote_path }` → `{ ok, bytes, content_base64 }`. SFTP get (≤100MB). - `ssh_update_connection` — `{ connection_id, name?, host?, port?, username?, auth_method?, private_key?|key_id?|password?, passphrase?, key_name? }` (owner-only) → `{ connection, retargeted, credential_changed, test }`. Edit a connection in place (the box moved IP/port, the login user changed, the key/password rotated) — keeps the id, so `ssh_run` and any sharing keep working. **Omit = keep** for every field, and credentials are OPT-IN: pass a credential ONLY to replace it. Changing host/port clears the pinned host key + the last-test verdict (the pin belonged to the old machine; keeping it would fail the next connect with a `host_key_mismatch` that is NOT an attack), a credential change clears the verdict — both are re-established by the auto-test whose result comes back as `test`. A failed test does NOT undo the edit. Secrets are never returned. WRITE action that can cut off access to a live server: confirm with the user first. - `ssh_update_connection_config` — `{ connection_id, instance_context?, instance_dont? }` (owner-only) → persists host facts + guardrails you learn from the user (does NOT change anything on the server). "" clears a field, omit keeps it. Use THIS, not `ssh_update_connection`, when only the AI context or guardrails change. ### Brain tools (`brain` scope) The workspace **Brain** — a standalone, workspace-scoped team knowledge base built on the Obsidian model: markdown notes whose titles form a `[[wikilink]]` namespace (unique per workspace, case-insensitive). A wikilink to a note that doesn't exist yet is fine — it resolves the moment that note is created ("ghost" links), and deleting a note just makes links to it unresolved again. Renaming a note automatically rewrites `[[Old Title]]` in every linking note. Inline `#tags` in bodies are picked up automatically. `![[Title]]` on its own line embeds the target note's content in the dashboard viewer (transclusion, one level deep — and an embed counts as a link). Every content-changing save snapshots a version (history + restore live in the dashboard — no MCP version tools). Every tool takes an optional `workspace_id` (id or slug); omitted = your personal workspace. Any workspace member reads + writes. **No tokens charged.** **Intended flow:** `brain_search` → `brain_get_note` (read the full note + follow its `links`/`backlinks`) BEFORE answering questions about the team's internal knowledge; write learnings back with `brain_create_note` / `brain_update_note` — link generously with `[[Other Note]]` so the graph grows into a real company brain. - `brain_search` — `{ query, workspace_id?, limit? }` → `{ total, has_more, results[] }`: FTS5 hits across titles/bodies/tags (prefix-matched), re-ranked so title matches come first and hub/index notes (many `backlinks`) outrank their own child notes. Each hit: `{ id, title, tags, excerpt, matched_in: ('title'|'body'|'tags')[], backlinks, updated_at }`. The `excerpt` carries NO highlight marks and keeps `[[wikilinks]]` intact — a `[[Title]]` in it can be passed straight to `brain_get_note { title }`. Read high-`backlinks` hits first (they map the topic); `matched_in: ["tags"]` alone means the note is filed under the topic rather than about your exact words; `has_more: true` means the page did not cover every match (raise `limit` or narrow the query). - `brain_get_note` — `{ id? | title?, workspace_id? }` → the full note (markdown body) + `links` (outgoing wikilinks; `to_note_id: null` = target doesn't exist yet) + `backlinks` (notes linking here) + `unlinked_mentions` (notes mentioning this title as plain text without linking it — suggest linking them) + `embeds` (resolved `![[Title]]` transclusion targets, one level deep). - `brain_list_notes` — `{ workspace_id?, tag?, limit?, offset? }` → metadata + preview, most recently updated first. - `brain_create_note` — `{ title, body_md?, tags?, workspace_id? }` → create. Titles must not contain `[ ] | #` or line breaks (they ARE the wikilink target). Use `[[Note Title]]` links + inline `#tags` in the body. - `brain_update_note` — `{ id, title?, body_md?, append?, tags?, workspace_id? }` → in-place update, "omit = keep". `append: true` concatenates `body_md` onto the existing body (the cheap "add a learning" path). A `title` change rewrites wikilinks in every linking note and reports `renamed_links_in`. - `brain_delete_note` — `{ id, workspace_id? }` → permanent delete (links to it become unresolved). Only when the user clearly asked. - `brain_graph` — `{ workspace_id? }` → `{ nodes, edges, ghosts, truncated }` — the knowledge graph. `ghosts` are linked-to titles with no note yet (good candidates to write next). - `brain_list_tags` — `{ workspace_id? }` → every tag with its note count (the topic map). ### Caya tools (`caya` scope) **Caya** is a digital mailroom — a service that scans a user's physical postal mail into PDFs organised in folders. Lynk talks to Caya's private backend on the user's behalf (Caya has no public API). Connections are strictly **PERSONAL** (scoped to the signed-in user, never shared) and hold the user's Caya login — Caya carries sensitive personal mail (invoices, official letters), so document PDFs come back **inline in the tool result, never as a public link**. No Lynk tokens charged. **Intended flow:** `caya_list_instances` → `caya_list_folders` (no `parent_id` → the inbox/archive/trash ids) → `caya_list_documents` on a folder → then read (`caya_download_document`) or sort (`caya_move` / `caya_trash` / `caya_mark_read` / `caya_tag`). Every id you pass comes from one of the list tools — folder ids from `caya_list_folders`, document/container ids from `caya_list_documents`. - `caya_list_instances` — `{}` → the caller's Caya connections (`id`, `name`, test status). Use the `id` as `instance_id` everywhere else. - `caya_list_folders` — `{ instance_id, parent_id? }` → omit `parent_id` for the three SYSTEM folders (`{ inbox, archive, trash }` as container ids — the entry point); pass a folder id to list its sub-folders. - `caya_list_documents` — `{ instance_id, folder_id }` → the documents in a folder: `{ id, filename, sender, subject, created_at, pages, file_url }`. - `caya_download_document` — `{ instance_id, folder_id, document_id }` → `{ filename, content_type, size, content_base64 }`. You must pass BOTH the folder and the document id (the folder is re-listed server-side to resolve the signed URL — no caller-supplied URL). Files > 20 MB are refused (download from the dashboard instead). - `caya_move` — `{ instance_id, ids: string[], to_folder }` → move documents/folders into a target folder (how you sort mail). This is the sort primitive. - `caya_trash` — `{ instance_id, ids: string[] }` → soft-delete (moves to the Trash folder). - `caya_mark_read` / `caya_mark_unread` — `{ instance_id, ids: string[] }`. - `caya_tag` — `{ instance_id, document_id, tags: string[] }` → add categorisation tags to one document. - `caya_create_folder` — `{ instance_id, title, parent_id? }` → new folder (root unless `parent_id`); returns its id (use as a `to_folder`). - `caya_rename_folder` — `{ instance_id, folder_id, title }`. ## Session lifecycle The server is stateful per session. Your client receives an `mcp-session-id` header on the `initialize` response and must include it on every subsequent request in this session. Sessions are pruned after 1 hour of inactivity — measured from the last request on the session, so an in-use session is never pruned out from under you. Calling `DELETE /mcp` (with the session header) ends the session cleanly. Only `initialize` opens a session. Any other request that doesn't resolve to a live session — a stale id, or no `mcp-session-id` header at all — is answered `404` with `{"error":{"code":-32001,"message":"Session not found — reinitialize"}}`; re-send `initialize` and continue on the new id. ## Token revocation & client management Users manage authorized clients at `https://api.lynk.run/settings/mcp`. The page lists every MCP client that currently holds valid access/refresh tokens, with a single-click revoke button. Revoking a client invalidates **all** tokens (access + refresh) issued to that `client_id` for that user. ## See also - Intel API spec: https://intel.lynk.run/llms.txt - Web dashboard & docs: https://api.lynk.run/docs - Per-user MCP setup: https://api.lynk.run/settings/mcp - Source / issues: https://github.com/jpj069/lynk-site