# 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. ## 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). - **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. 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?) | | `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`, plus the Lynk-native KB tools — manage tickets, people, and KB articles 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_search`, `salesforce_describe_global`, `salesforce_describe`, `salesforce_get_record`, `salesforce_slippage_report`, `salesforce_list_files`, `salesforce_download_file`, `salesforce_update_instance_config` — read-only SOQL/SOSL queries, object metadata, single-record reads, file list + download (Salesforce Files + legacy attachments), and the period-over-period Slippage Report across your connected Salesforce orgs. Every `salesforce_list_instances` entry carries `instance_context` (account facts) + `instance_dont` (hard guardrails) — read both before acting; persist them with the owner-only `salesforce_update_instance_config`. | | `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_screenshot`, `browser_scroll`, `browser_click`, `browser_drag`, `browser_long_press`, `browser_type`, `browser_select_option`, `browser_press_key`, `browser_set_viewport`, `browser_fill_credentials`, `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); 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. | | `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_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. `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). | | `canvas` | `canvas_publish`, `canvas_create`, `canvas_append_chunk`, `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_set_bulk`, `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), build a LARGE page up across calls (create → append chunks → publish), 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). | 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. ## Tools reference ### `intel_lookup` Input: `{ target: string, mode?: 'lite' | 'medium' | 'full' }`. Modes: `lite` (local MMDB + DNS, ~10-50 ms), `medium` (+ rDNS/WHOIS/SSL, no DNSBL, ~200-800 ms), `full` (everything incl. 73 IP RBLs + 8 domain RHSBLs, ~1-3 s). **`null` for a field means "not checked in this mode", NOT "checked and clean".** ### `bgp_company_search` Input: `{ query: string, limit?: number }`. Returns array of `{ asn, name, org, domain, country, prefixes[], prefix_count, total_ips, relationships: { upstreams, peers, customers } }`. Free, no key needed in the underlying data sources (CAIDA + iptoasn). ### `domain_check` Input: `{ name?: string, tlds?: string[], domains?: string[], variations?: boolean }`. Pass `name` (a bare label, e.g. `mycoolstartup`) to fan out across `tlds` (defaults to `com, net, org, io, ai, app, dev, co, me, xyz, sh`), and/or `domains` for explicit full domains. Set `variations: true` to also fan the bare `name` across common startup affixes — prefixes `get, try, use, go, my, join, the, hey` + suffixes `app, hq, hub, ly, now, 24, labs, ai, io` (so `acme` also yields `getacme`, `acmehq`, `acme24`, … on each TLD), so you don't have to construct the affixed names yourself. Subdomains are reduced to the registrable domain (eTLD+1). Max 5000 domains per call (MCP/authenticated). Returns `{ summary: { total, available, taken, unknown }, invalid[], results[] }`; each result is `{ domain, tld, status, 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.** `taken` results are cached 24 h, `available` results 10 min. ### `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, ... }`. 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_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). ### 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. ### Deskpro KB tools (`deskpro` scope, KB sub-family) A **Lynk-native** knowledge base scoped per **Deskpro site** (`base_url`), not per Lynk user. Every user with a currently-verified API key (`last_test_ok=1`) for the same site shares the same KB, wiki-style. Articles persist when individual users disconnect — only the canonical Deskpro site URL gates access. The Deskpro built-in KB is NOT exposed by these tools; this is Lynk's own collaborative store on top of FTS5. #### `kb_search` Input: `{ instance_id, query, limit? }`. SQLite FTS5 full-text search across title + body + tags, bm25-ranked. Returns `{ articles, query, base_url }` with one `body_preview` (first 280 chars) per hit. Use `kb_get` for the full body. User input is sanitised — FTS5 operators like `AND` / `OR` / `NEAR` are stripped before the query is executed, so they can't be injected. #### `kb_list` Input: `{ instance_id, limit?, offset? }`. Newest-first list of every article for the site. Returns `{ articles, total, limit, offset, base_url }` where each article carries `id`, `title`, `tags`, `body_preview`, dates and `created_by_user_id`. #### `kb_get` Input: `{ instance_id, article_id }`. Returns the full markdown body. The article must belong to the same Deskpro site as the instance (a wrong-site lookup returns "Article not found", not a permission error). #### `kb_create` Input: `{ instance_id, title, body_md, tags? }`. Creates an article on the instance's Deskpro site. `body_md` is markdown, max 200 KB. `tags` is capped at 10 entries × 40 chars each. #### `kb_update` Input: `{ instance_id, article_id, title?, body_md?, tags? }`. Wiki-style edit — any user with a working API key for the same Deskpro site can update the article. Pass only the fields you want to change. #### `kb_delete` Input: `{ instance_id, article_id }`. Hard delete. Same any-connected-user rule as `kb_update` — there is no per-user ownership on KB articles by design. ### `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 three 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_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 ALWAYS redacted in MCP responses.** The list (defined as `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 the literal string `"__REDACTED__"` before the response reaches the AI agent. There is no opt-out — the REST API has an `?include_sensitive=1` flag for workspace owners, but it is NOT exposed via MCP. **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. Each entry: `id`, `name`, `workspace_name`, `workspace_slug`, `hr_configured`, `recruiting_configured`, `hr_last_test_ok`, `recruiting_last_test_ok`. - `personio_list_employees` — `{ instance_id, email?, updated_since?, limit?, offset? }` → employee directory with sensitive fields redacted. `limit` default 50, max 200. - `personio_get_employee` — `{ instance_id, employee_id }` → one employee, redacted. - `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, draft_id?: string, title?: string, 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), or `draft_id` (finalize a chunked draft built with `canvas_create` + `canvas_append_chunk`). **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`. Markdown is one-shot only (not the chunked draft flow). `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 }`. With `draft_id`, the title/password/slug/expiry passed here override whatever was staged at `canvas_create`. **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. `password_kind` picks the gate style: `'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_create` — `{ html?, title?, password?, password_kind?, expires_in_days?, slug? }`. Starts a **chunked publish draft** for a LARGE document. Returns `{ draft_id, chunks, bytes, bytes_remaining, expires_at }`. Optional first `html` chunk + any metadata to stage (overridable at publish). FREE — no token charge until `canvas_publish`. Drafts auto-expire after 1h. Use this instead of a single `canvas_publish` when the document is too big to emit reliably in one tool call. - `canvas_append_chunk` — `{ draft_id, html }`. Appends the next slice of HTML to a draft (concatenated verbatim, in order). Call repeatedly, then `canvas_publish` with the `draft_id`. Returns `{ draft_id, chunks, bytes, bytes_remaining }`. Caps: 512 KB/chunk, 200 chunks, 2 MB assembled total. - `canvas_update` — `{ id, html?, markdown?, title?, 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` (not both) to replace the content; 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. `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 (metadata only, no HTML body): id, title, size, view_count, has_password, expiry, `slug`, `view_url`, `short_url`, `folder_id`. - `canvas_get` — `{ id: string }`. Returns one page **including its full `html`** plus `content_format` (`'html'`|`'markdown'`); 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. - `canvas_list_folders` — `{}`. Lists your folders: `{ count, folders: [{ id, name, visibility, allowed_emails, page_count, url }] }`. 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. - `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 EITHER `content_base64` (raw bytes base64-encoded) OR `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). 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`. 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? }` — omit key for the full `{key:value}` map), `canvas_kv_set` (`{ id, key, value }`), `canvas_kv_set_bulk` (`{ id, data: {key:value} }` — write MANY keys in one atomic call, the way to SEED a page's data store; all caps validated against the final state, so an over-cap bulk is rejected whole; existing keys overwritten, others untouched; returns `{ count, total_bytes }`), `canvas_kv_delete` (`{ id, key? }` — omit key to clear all). Owner-scoped. - 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). ### 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 }] }`. `role` marks well-known folders (inbox | sent | drafts | trash | junk | archive). 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_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. ### 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? }` → `{ id, status, control_mode, view_url, ... }`. Starts a session (= one isolated browser context). 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`). Cookies persist **encrypted** at rest per profile. 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. Pass `relaxed: true` to run in a **separate higher-memory container with Chromium flags that keep SSO login iframes working** — use it ONLY for heavy SSO portals (the Microsoft/Azure/Entra console) that fail to load in a normal session; same cost. Default off. **`view_url`** (`https://api.lynk.run/browser/?session=`) deep-links the user straight to this session's live view + takeover — **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. - `browser_navigate` — `{ session_id, url }` → go to an http(s) URL. - `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_screenshot` — `{ session_id }` → JPEG (base64) of the active tab. Also "observes". - `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. - `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 ``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_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. 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_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_save_credentials` — `{ label, domain, username?, password }` → 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. 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 2FA, read the code from the user's connected Gmail (`gmail` scope) and type it, or hand off. (3) Tabs/popups (OAuth/consent windows) open inside the same session and the live view follows them automatically. (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 `` 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. ### 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. Agents can create a project + a distributable recording link; 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 `