mesharc
The Python client for the MeshArc API: a URL in, clean content out, and a record of what changed.
- Scrape one page or a batch — markdown, text, HTML, links, structured fields, a screenshot.
- Crawl a whole site with no project to set up first, and keep it as one if it turns out to be worth watching.
- Map what a site declares in its sitemaps before fetching any of it.
- Search the web for pages whose URLs you do not know, and read each result's page in the same call.
- Ask the agent a question: it searches, reads pages and answers, in the shape of a schema if you give one, with the pages its answer rests on. (Not live on mesharc.dev yet.)
- Monitor a search or a question: asked again hourly, daily or weekly, each answer compared with the last, a webhook told when it changed. (Not live on mesharc.dev yet.)
- Watch a site over time: projects, scheduled runs, and a change record — pages added, removed, modified, field by field.
Python 3.10 or newer. One dependency (httpx). Fully typed.
Install
Authentication
Every call needs an API key. Create one in the app under Settings → API keys — it is shown once — and give it to the client, or put it in MESHARC_API_KEY and construct the client with nothing:
from mesharc import MeshArc
arc = MeshArc("mesharc_...")
arc = MeshArc()
The key is only ever sent as a bearer header to api.mesharc.dev.
A key carries the scopes it was made with (read, write, admin), optionally a set of projects it may see, an expiry and a rate limit. A route the key may not use answers 403; a project it may not see answers 404.
Quick start
from mesharc import MeshArc
arc = MeshArc("mesharc_...")
page = arc.scrape("https://example.com/pricing")
print(page["markdown"])
print(page["verdict"], page["method"], page["credits"])
scrape holds the request open until the page comes back (60 s by default), so there is nothing to poll for an ordinary page.
Reading pages
One page
page = arc.scrape("https://quotes.toscrape.com/js/", config={"render_js": "always"})
config is any setting a project takes, by its API name (render_js, only_main_content, formats, max_tier, wait_for_selector, actions, …). The full list, with defaults, is at mesharc.dev/docs/configuration.
page = arc.scrape_one(
"https://example.com/",
formats="markdown,text,cleanHtml",
timeout_s=120,
idempotency_key="pricing-2026-09-18",
)
Many pages
A list of URLs is a batch: grouped by host, fetched in parallel where the config allows, and returned as one row per URL.
batch = arc.scrape(["https://a.com/", "https://b.com/x"], config={"concurrency": 4})
for row in batch["pages"]:
print(row["url"], row["httpStatus"], row["verdict"], row["credits"])
arc.scrape(urls, wait=False) returns the batch id at once; arc.batch(id, wait=True) finishes it later. Pass webhook_url= to be told instead of polling (batch.finished, signed with a secret returned once).
What a page looks like
Every page row carries the same fields, whether it came from a scrape, a crawl or a project:
| Field | Meaning |
|---|
markdown, text, cleanHtml, html, links, fields, screenshot | The bodies you asked for |
httpStatus | The status the site answered with |
verdict | ok, thin (short, but a page), blocked (refused, or a 404), skipped |
errorCode | OK, or what went wrong: BLOCKED, NOT_FOUND, TIER_LIMIT, CAPTCHA, LOGIN_REQUIRED, RATE_LIMITED, TIMEOUT … |
shape, warnings, signals | listing / table / form for a short page whose markup says what it is; short; why the judge decided as it did |
method, tier, climbedTo | The engine that read it (crawler, tls, minted, browser, browser-residential, …), its tier, and how far a refused page climbed |
credits, billedAs | What it cost; the rung it is priced at when not the one that fetched it |
words, language, head, reason, crawledAt | Size, language, the head fields, the judge's sentence, when |
A page the site refused costs 0, and so does a 404.
Crawling a site
job = arc.crawl(
"https://docs.example.com",
limit=200,
maxDepth=3,
includePaths=["/docs/*"],
crawlMode="sitemap_first",
maxTier="browser",
scrapeOptions={"formats": ["markdown", "links"]},
config={"crawl_delay_ms": 500},
)
for page in job.pages():
print(page["url"], page["words"])
print(job.status, job.envelope["counts"], job.envelope["creditsUsed"])
crawl returns a handle immediately; job.pages() yields pages as they land and ends when the crawl does. wait=True blocks until it finishes; job.wait(), job.refresh(), job.cancel() do what they say; arc.get_crawl(id) reattaches to a crawl started elsewhere.
A one-shot crawl expires after 30 days. If the site is worth watching:
project = job.keep(name="Docs", schedule="weekly")
A webhook= in the options ({"url", "events", "metadata"}) is told about crawl.started, crawl.page (fifty pages a message) and crawl.completed; its signing secret comes back once as job.webhook_secret.
Mapping a site
for u in arc.map("https://docs.example.com"):
print(u["url"], u["lastmod"])
details = arc.map_details("https://www.gov.uk/", search="visa", limit=500)
print(details["totals"], details["creditsUsed"])
A map costs one credit per sitemap file read — most sites are one file.
Searching the web
out = arc.web_search(
"python packaging guide",
limit=5,
freshness="month",
include_domains=["python.org"],
scrape=True,
)
if out["status"] == "blocked":
print(out["attempts"])
else:
for hit in out["data"]:
print(hit["position"], hit["title"], hit["url"], hit["snippet"])
if hit.get("page"):
print(hit["page"].get("markdown", "")[:200])
print(out["engine"], out["cached"], out["creditsUsed"])
again = arc.get_search(out["id"])
for s in arc.searches(q="python"):
print(s["id"], s["query"], s["status"], s["resultCount"], s["creditsUsed"])
news = arc.web_search("python release", news=True, freshness="day")
for hit in news["data"]:
print(hit["publisher"], hit["age"], hit["title"])
more = arc.web_search("python packaging guide", page=2)
web_search waits for the results and returns the whole envelope, the hits under data. A search is queued, running, done, blocked or error: blocked — every engine refused — is returned, not raised; error raises MeshArcError. A search that scrapes stays running until its pages land. scrape= also takes a dict, sent as given ({"formats": ["markdown", "links"], "maxCredits": 20}); country= and lang= say where and in what language to search; wait=False returns at once, and timeout_s is how long the API holds the request (60 s by default, 120 at most) before the client polls.
news=True searches the engines' news results rather than the web's (one or the other per search); each news hit also carries publisher and age, as the engine put it ("20h"). page= is which results page, 1 to 10: page 2 is results 11–20, and each page is its own search with its own charge. news= and page= need an API with those features: they are on the API's feature branch, not yet on mesharc.dev.
A search needs a key that can write, and it spends credits. The results page costs the engine that read it; a results page every engine refused is free. An equal search — same query, news, page, country, lang, freshness and domains — within an hour of a finished one (ten minutes for news) comes from the cache (cached is true) with no charge for the results page. Scraped pages are charged, cached or not.
arc.search(project_id, q) is something else: it searches the pages a project has stored, not the web (see below).
Asking the agent
The agent route (/agent) is not live on mesharc.dev yet; web search is. The client and the MCP tools are ready for it. The agent's webhook, connection_id, continue_() and field_sources need an API with those features too: they are on the API's feature branch, not yet on mesharc.dev.
The agent takes a question, searches the web, maps sites and reads pages on its own, and answers with the pages it used. Give it a schema and the answer comes back in that shape:
run = arc.agent(
"What does each plan on example.com cost per month?",
urls=["https://example.com/pricing"],
allowed_domains=["example.com"],
schema={
"type": "object",
"properties": {"plans": {"type": "array", "items": {"type": "object", "properties": {
"name": {"type": "string"}, "monthly": {"type": "number"}}}}},
},
max_credits=500,
max_steps=20,
)
out = run.wait()
print(out["status"], out["creditsUsed"], out["steps"])
print(run.data)
for src in run.sources:
print(src["url"], src["title"])
print(run.field_sources)
agent returns an AgentRun at once, queued or running; the prompt is up to 10,000 characters. timeout_s= asks the API to hold the request for the answer (120 s at most). Without a schema the answer is {"text": ...}. field_sources says where each value of the answer came from: its path ("plans[0].monthly", or "[2].name" for a list answer) to {url, pageId}, a page the run read.
run = arc.agent(
"What does each plan on example.com cost per month?",
webhook={"url": "https://hooks.example.com/mesharc", "events": ["agent.completed", "agent.failed"]},
connection_id="conn_...",
)
print(run.webhook_secret)
webhook= is a URL, or {"url", "events", "metadata"}, told about agent.started, agent.action, agent.completed, agent.failed and agent.cancelled (all five by default); its signing secret comes back once, as run.webhook_secret; the handle continue_() returns carries the stopped run's secret on (the new run keeps the same webhook), and a run reopened with get_agent has none. connection_id= runs the agent on one of the workspace's own LLM connections — its model, its key, its bill.
To watch it work, follow the trace:
run = arc.agent("Who maintains the httpx library, and where is it developed?")
for event in run.trace():
print(event["seq"], event["kind"], event["text"])
print(run.wait()["data"])
kind is start, resume, continue, model, search, fetch, render, map, select, extract, tool, busy or finish. trace(after=seq) resumes after an event already seen; follow=False yields what exists and returns.
run.cancel()
run.refresh()
again = arc.get_agent(run.id)
for r in arc.agent_runs(status="done"):
print(r["id"], r["status"], r["creditsUsed"], r["prompt"])
from datetime import date
for r in arc.agent_runs(model="openai:gpt-5.4-mini", since="2026-10-01", until=date.today()):
print(r["id"], r["model"], r["createdAt"])
A run is queued, running, done, error, cancelled or credit_limit. wait() returns a run that is done, cancelled or stopped at credit_limit — the run reached max_credits, and what it had found is under data["partial"] — and raises MeshArcError 502 job_failed for error, with the API's text -- and for any other end it does not know, as the Node client does. A cancel stops a queued run at once and a running one before its next step; what it used stays charged. arc.cancel_agent(id) does the same by the run's id, with no handle needed.
agent_runs filters by status, by model (as runs name it) and by when the run was made: at or after since, before until, each an ISO 8601 date or date-time string or a date / datetime. A date means its midnight UTC; a date-time without a zone is read as UTC (so a naive datetime.now() is your local time read as UTC — pass datetime.now(timezone.utc) instead).
A run that stopped at credit_limit can be carried on with a new budget:
out = run.wait()
if out["status"] == "credit_limit":
more = run.continue_(max_credits=1_000)
print(more.continues_run_id)
print(more.wait()["data"])
continue_() returns an AgentRun on the new run, which resumes from where the stopped one was, keeps the pages it read (not paid for again) and its webhook, whose secret the new handle's webhook_secret carries on. max_credits and max_steps are the new run's own; left out, they are the stopped run's. The stopped run's continued_by names the new one once refreshed. A run that did not stop at its credit limit, was already continued or has no saved progress raises MeshArcError 409 with code conflict; one past its keep date, 410 expired.
A run needs a key that can write, and it spends credits: each page it reads is charged as a read (a refused page is free), and the model's tokens at the model provider's price plus 20% — or 1 credit per 1,000 tokens on the workspace's own connection — all within max_credits. A run is kept 7 days; after that get_agent raises MeshArcError with status 410 and code expired, and trace() ends quietly.
Monitors: a search or a question, asked again on a schedule
Monitors (arc.monitors, the /monitors route) need an API with that feature: it is on the API's feature branch, not yet on mesharc.dev.
monitor = arc.monitors.create(
"search",
{"query": "mesharc", "freshness": "day"},
"daily",
name="Mentions",
webhook="https://hooks.example.com/mesharc",
)
print(monitor["webhookSecret"])
for r in arc.monitors.runs(monitor["id"]):
print(r["createdAt"], r["status"], r["changed"], r["summary"])
press = arc.monitors.create("search", {"query": "mesharc", "sources": ["news"]}, "hourly")
arc.monitors.update(monitor["id"], schedule="weekly")
arc.monitors.update(monitor["id"], webhook="")
Each run is the ordinary search or agent run, charged as one, and is compared with the last run that answered: new, dropped and moved results for a search, the answer's added, changed and removed values for an agent run. A run's refId is the search or agent run it made (arc.get_search / arc.get_agent).
The request is the body of a POST /search or POST /agent in the API's own (camelCase) names — {"query": ..., "includeDomains": [...]}, {"prompt": ..., "schema": ..., "maxCredits": ...} — not web_search's or agent's Python arguments. So a news monitor says "sources": ["news"]: web_search(news=True) maps its argument to that, and monitors.create does not. A monitored search reads its results page only, so scrape is refused. baseline_id= takes a finished search or agent run with the same request as the first run, not paid for again (for a search, the baseline's limit must be at least the monitor's); without it the first run starts now.
| Method | What it does |
|---|
monitors.create(kind, request, schedule, name=None, webhook=None, baseline_id=None, idempotency_key=None) | Keep a search or agent request on a schedule |
monitors.list(kind=None) · monitors.get(id) | The workspace's monitors, each with its lastRun; one monitor |
monitors.update(id, name=None, schedule=None, status=None, webhook=None) | Change what is not None; status is active or paused, webhook="" removes the webhook |
monitors.pause(id) · monitors.resume(id) | Stop and restart the schedule; a slot missed while paused runs once on resuming |
monitors.run(id, idempotency_key=None) | Run it now; 409 conflict while a run is under way |
monitors.runs(id, limit=25) | Its runs, with changed, summary and diff, following next |
monitors.delete(id) | The monitor and its runs; the searches and agent runs it made stay |
Creating and running a monitor spend credits. Every call but monitors.list, monitors.get and monitors.runs needs a key that can write. A webhook's secret comes back once, as webhookSecret, on the call that set it. arc.monitor() is something else: the workspace's job queue, what is queued and running (see below).
Watching a site: projects and runs
project = arc.projects.create(
"https://docs.example.com",
name="Docs",
schedule="weekly",
config={"max_pages": 300, "include_paths": ["/docs/*"]},
)
run = arc.runs.start(project["id"], wait=True)
record = arc.changes(project["id"])
print(record["change"]["counts"])
diff = arc.page_diff(project["id"], "https://docs.example.com/pricing")
| Method | What it does |
|---|
projects.list() · projects.get(id) · projects.update(id, name=, schedule=, retention=, config=) · projects.delete(id) | The projects |
runs.list(project_id) · runs.start(project_id, wait=) · runs.wait(project_id, run_id) · runs.get(project_id, run_id) · runs.cancel(project_id, run_id) | Runs |
pages(project_id, run_id=None) · page(project_id, url, run_id=None) | The pages of a run; one page in full |
changes(project_id, run_id=None) · page_diff(project_id, url, run_id=None) | The change record; one page's word-level diff |
search(project_id, q, mode="content" | "selector", run_id=None) | Which pages say this (words, "phrases") or contain this (CSS / XPath) |
recrawl(project_id, urls) | Fetch these pages again, now |
sources(project_id) | The seed, sitemap, URL list, feeds and patterns with what the last run found through each |
export(project_id, path, dataset="pages", fmt="jsonl", run_id=None, urls=None) | Stream a dataset (pages, markdown, changes, fields, sitemap) as jsonl or csv to a file |
arc.export(project["id"], "pages.csv", dataset="pages", fmt="csv")
The workspace
me = arc.me()
usage = arc.usage()
queue = arc.monitor()
meta = arc.meta()
keys = arc.keys()
key = arc.create_key("ci", scopes=["read", "write"], projects=[project["id"]], expires_in_days=90)
arc.revoke_key(key["id"])
Errors
Every failure raises MeshArcError:
from mesharc import MeshArc, MeshArcError
try:
arc.crawl("https://example.com", limit=1_000_000)
except MeshArcError as exc:
print(exc.status, exc.code, exc.detail, exc.request_id)
code | Status | Meaning |
|---|
validation | 400 / 422 | Something in the request is wrong; detail says what |
unauthorized | 401 | No key, or a revoked or expired one |
plan_limit | 402 | The plan does not include this, or the credits are spent |
forbidden | 403 | The key's scopes do not allow it |
not_found | 404 | No such thing — or not one this key may see |
conflict | 409 | The request contradicts current state |
expired | 410 | An agent run past its keep date (7 days) |
rate_limited | 429 | Over the key's rate limit; X-RateLimit-Reset says when |
internal | 500 | Quote request_id to support |
request_id is the id the API put on the response and in its own logs, so a support conversation starts from one string.
Two more cases: a network failure or a request that hits timeout raises MeshArcError with status == 0 and code network or timeout; a job the client stopped waiting for raises MeshArcTimeoutError — both a MeshArcError and a TimeoutError — which carries job_id so you can poll it later (arc.get_crawl(id), arc.batch(id), arc.get_search(id), arc.get_agent(id)).
Idempotency and timeouts
scrape, scrape_one, crawl, web_search, agent, AgentRun.continue_, monitors.create and monitors.run take idempotency_key=: send the same key again within 24 hours and you get the first answer back rather than a second job. The MCP server's search_web tool sends none, on purpose: an equal search within the hour of a finished one comes from the cache anyway. Its run_agent and continue_agent tools send their own, made from their inputs and the ten-minute window, so a retry in the same ten minutes lands on the same run and a re-run later is a new one.
- Waiting calls take
poll= (seconds between polls) and timeout= (seconds before TimeoutError), and most take wait=: wait=False returns the envelope at once. The defaults differ: scrape, batch, crawl (and Crawl.wait, Crawl.pages) and runs.start / runs.wait poll every 3 s for up to an hour; scrape_one and web_search every 2 s for up to ten minutes; map / map_details and extract every 2 s for up to five minutes; AgentRun.wait and AgentRun.trace every 2 s for up to an hour.
timeout_s on a single scrape is how long the API itself holds the request open (60 s by default, 120 at most); a slower page comes back as an id and is polled.
MeshArc(..., timeout=150.0) is the HTTP timeout per request. A request is retried on 429, 502, 503, 504 and network failures when it is safe to repeat — a GET, a DELETE, or a POST with an idempotency key — up to max_retries times (2), honouring Retry-After.
Credits
Every response says what it cost: credits on a page, creditsUsed on a job envelope, X-MeshArc-Credits on the HTTP response. A page costs the engine that read it — a plain fetch 1, a render 4 — and a refused page or a 404 costs nothing. A web search's results page costs the engine that read it, and nothing when every engine refused; an equal search (same query, news or web, results page, country, lang, freshness and domains) within an hour of a finished one (ten minutes for news) comes from the cache with no charge for the results page, while the pages it scrapes are charged either way. An agent run pays for each page it reads as a read (a refused page is free) and for the model's tokens at the model provider's price plus 20% (1 credit per 1,000 tokens on the workspace's own LLM connection), within its max_credits (2,000 by default); creditsUsed counts both. A monitor's runs are charged as the searches and agent runs they are. The schedule and the plans are at mesharc.dev/docs/billing.
The MCP server
The package also ships MeshArc as an MCP server, so Claude Desktop, Claude Code, Cursor and any MCP client can scrape, crawl, map, search the web, hand a question to the agent and read change records as tools. Python 3.10+.
There is a hosted one, so most people need install nothing:
claude mcp add --transport http mesharc https://mcp.mesharc.dev/mcp
It opens a browser once to sign in to mesharc.dev and approve, then works. Approving issues the app an API key of its own, which you can see and revoke under Settings → API keys; read only unless you allow changes. Any client that takes a remote MCP URL — the Claude.ai and ChatGPT connectors, Claude mobile, Cursor — takes it the same way.
Or run it yourself, over stdio, with your own key:
pip install "mesharc[mcp]"
MESHARC_API_KEY=mesharc_... mesharc-mcp
claude mcp add mesharc -e MESHARC_API_KEY=mesharc_... -- mesharc-mcp
Tools: scrape_urls, map_site, crawl_site, search_web, list_projects, get_project, create_project, update_project, delete_project, start_run, list_runs, list_pages, get_changes, get_job, cancel_job, run_agent and continue_agent (the last two not live on mesharc.dev yet: they need the API's agent features, on its feature branch). get_project shows a project's webhook and its recent deliveries, and update_project sets it and sends it a test (test_webhook=true). Every tool is a call through this client, and each one's answer is described by an output schema. A tool that fails answers {error, status}, with the API's code and request_id as well when it gave them.
A result with many pages in it is a map, not the territory: a crawl answers with an index of the pages read (up to 500, fewer if the budget needs the room), an excerpt of as many as a 60,000-character budget pays for (fifty at most), and a count of each page's links rather than the links. Ask for the one page you want on its own with get_job(kind="crawl", id=…, url=…), or carry on through the crawl with cursor=. A multi-URL scrape past its budget counts the rest by status and names any that did not come back ok. One page asked for on its own -- list_pages with url, or scrape_urls with a single url (with full=true for every format) -- comes back whole, capped at 12,000 characters. A search_web answer keeps within the same 60,000 characters, a scraped result's page as an excerpt; scrape_urls with full=true reads one in full. So does an agent's answer: past the budget its fieldSources (where each value of the answer came from) are left out first, then its sources are cut, to the first 50 (fewer if they would take more than half the room), and only then is data replaced by its JSON text, cut, with a note saying where the full answer is -- GET /api/v1/agent/{id}, or get_agent(id) in this client.
Hosted, a connection can be approved read-only, and read-only cuts in a place worth knowing: it reads the whole workspace, but it cannot reach the site. Anything that fetches is a write, because it leaves the workspace and usually spends its credits -- map_site reads only a site's sitemaps, at a credit per sitemap file, and it is gated with the rest. list_projects, get_project, list_runs, list_pages, get_changes and get_job work; the eleven that fetch or write -- scrape_urls, map_site, crawl_site, search_web, run_agent, continue_agent, create_project, update_project, delete_project, start_run, cancel_job -- answer {"code": "read_only"} with what to do about it, until the app is reconnected with 'Also allow changes' ticked (write access). delete_project needs more than write: an admin API key, which a connected app never holds, so it only works run locally with such a key.
Hosted, a tool that would hold a connection open for minutes — a crawl, a run you asked to wait for, a multi-URL scrape, a web search — hands back a job after MESHARC_MCP_WAIT seconds (25 by default, because many MCP hosts time a tool call out sooner). The work carries on server-side; get_job picks it up and cancel_job stops a crawl, run, batch or agent run. A search that scrapes, with its results in and its pages still landing, answers with the results and a job for the pages. Run locally, those tools block as they always have.
run_agent keeps an agent given starting urls on those URLs' sites unless allowed_domains says otherwise, as the app does: a page it reads could tell it to go elsewhere. allowed_domains=[] lets it read any site, and with no urls there is nothing to hold it to. (The client's agent() sends what it is given, as the API takes it: no allowed_domains is no limit.)
run_agent does not wait at all, hosted or local: an agent works for minutes, so it hands back a job at once, with what the run has spent so far. get_job(kind="agent", id=…) follows it -- every 30 to 60 seconds -- and answers with the result once it finishes; cancel_job(kind="agent", id=…) stops it before its next step, and what it used stays charged. A run is kept 7 days; after that get_job answers status: "expired". A retry that lands on a run already finished answers with its result. A run that stopped at its budget (status: "credit_limit", what it had found under data.partial) is carried on by continue_agent(id=…, max_credits=…): a new run on the same thread that resumes where it stopped and keeps the pages it read, not paid for again. It hands back the new run's job the same way. A run that did not stop at its limit is refused with code conflict; asked again for a run already carried on — a retry past the ten-minute key window included — it answers with the run that carried it on, and starts nothing.
Hosting it yourself
MESHARC_MCP_PUBLIC_URL=https://mcp.example.dev/mcp MESHARC_OAUTH_ISSUER=https://api.example.dev MESHARC_INTROSPECT_SECRET=... mesharc-mcp --http --host 127.0.0.1 --port 8040
The server holds no key. Each request carries the caller's OAuth token, verified against MESHARC_OAUTH_ISSUER and then used to make the call, so one process serves many workspaces. MESHARC_API_KEY is never read in this mode; if it is set, startup says it is being ignored.
Behind a reverse proxy, note that binding 127.0.0.1 makes the MCP SDK enable DNS-rebinding protection with a localhost-only Host allow-list. The server therefore passes its own, built from MESHARC_MCP_PUBLIC_URL — without which every proxied request would be refused. Add browser-based clients to the Origin allow-list with MESHARC_MCP_ALLOWED_ORIGINS (comma-separated).
Privacy and security
The client talks to one host — the API base URL, https://api.mesharc.dev unless MESHARC_API_URL or base_url= says otherwise — and to nothing else. The key travels only as a bearer header, only over HTTPS. Nothing is written to disk and no telemetry is sent. The client reads only MESHARC_API_KEY and MESHARC_API_URL; the MCP server in HTTP mode reads MESHARC_MCP_PUBLIC_URL, MESHARC_OAUTH_ISSUER, MESHARC_INTROSPECT_SECRET, MESHARC_MCP_WAIT and MESHARC_MCP_ALLOWED_ORIGINS, and no key of its own.
What MeshArc keeps about you and about the pages you crawl, and for how long, is in the privacy policy. How the service is secured is on the security page. To report a vulnerability in this client or in the service, write to security@mesharc.dev rather than opening a public issue — see SECURITY.md.
Anything else
The client is a thin wrapper: every method is one API call and returns the API's JSON as a dict. The full reference is at mesharc.dev/docs/api. Call arc.close() when you are done, or use the client as a context manager.
MIT.