SDKs
Official clients for Python, JavaScript/TypeScript, and Go. All three wrap the same API, return the same verdict shape, and can talk either to the hosted service or to a local scanner binary with no code change.
They install directly from GitHub, so there is no registry package to wait on and you can pin to a commit for reproducible builds.
Install#
Python#
Requires Python 3.11 or newer.
uv pip install "git+https://github.com/tendrl-inc-labs/surface-python"
pip install "git+https://github.com/tendrl-inc-labs/surface-python"
JavaScript / TypeScript#
npm install github:tendrl-inc-labs/surface-js
The package compiles itself during install. Newer npm versions may ask you to approve that lifecycle script; if scripts are blocked, the dist/ output will be missing and imports will fail.
Go#
go get github.com/tendrl-inc-labs/surface-go
Pinning#
Installing from the default branch gets you whatever is on it. Pin to a tag or commit for builds you need to reproduce:
uv pip install "git+https://github.com/tendrl-inc-labs/surface-python@v0.1.0"
npm install github:tendrl-inc-labs/surface-js#v0.1.0
go get github.com/tendrl-inc-labs/surface-go@v0.1.0
Authenticate#
All three fall back to the SURFACE_KEY environment variable, so the key does not need to appear in code:
export SURFACE_KEY="<token-from-Access-Control>"
See Getting Started for creating one.
Scan a file#
Python#
from surface import SurfaceClient
surface = SurfaceClient()
result = surface.scan_file("upload.pdf")
if result.safety_score.recommended_action != "Allow": # Block or Review
raise ValueError("rejected upload")
JavaScript#
const surface = new SurfaceClient();
const result = await surface.scanFile("upload.pdf");
if (result.safetyScore.recommendedAction !== "Allow") { // Block or Review
throw new Error("rejected upload");
}
Go#
client, err := surface.NewClient("") // falls back to SURFACE_KEY
if err != nil {
return err
}
result, err := client.ScanFile(ctx, "upload.pdf", nil)
if err != nil {
return err
}
// ScanFile returns a *ScanFileResult; the verdict lives on result.ScanResult
// (nil only for deferred scans, which this sync call is not).
if result.ScanResult.SafetyScore.RecommendedAction != "Allow" { // Block or Review
return errors.New("rejected upload")
}
For uploads, refuse Review as well as Block#
Surface returns Block only when it has precise evidence: a known-malware hash, an antivirus signature, a malware rule match, or a confirmed prompt injection in the file. Malware recognized only by the ML models and behavior checks comes back as Review, because an estimate is not proof and a wrong Block breaks someone's upload. On new malware that has no signature yet, Review is where most detections land.
So for a file upload, treat Review as "do not accept this file as-is". Refuse it, as the examples above do, or hold it for a person to look at (below). Accepting Review silently lets through most of the malware Surface catches.
Agent tool calls are different. There, Review means "confirm with the user", and the tool-call pattern branches on it explicitly.
Branch on recommendedAction, not on the threat level#
recommendedAction is one of Allow, Review, or Block, and it is the field to branch on.
Checking threatLevel == "Clean" instead is a common mistake with a real cost. A file whose format has no ML model behind it caps at Informational by design, and Informational still means Allow — so gating on Clean silently rejects uploads Surface told you to accept. A .jar is the usual casualty. See Detection Coverage for which formats those are.
action = result.safety_score.recommended_action
if action == "Block":
return error("We couldn't accept that file.")
if action == "Review":
quarantine(file) # hold it for a human
return ok("Pending review.")
return store(file)
The SDKs can also raise on your behalf instead of returning a verdict, which suits an upload handler that should simply refuse:
from surface import SurfaceClient, MaliciousFileError
try:
SurfaceClient().scan_file("upload.pdf", reject=["Block", "Review"])
except MaliciousFileError:
return error("We couldn't accept that file.")
Screen an agent's tool calls#
Action screening scans a tool call your agent is about to make and judges it by its effect (see Detection Coverage). It runs in your code, around tool execution — it is not automatic and the model does not do it for you. The pattern is the same in every language: your agent proposes a tool call, you scan that call with context built from your own trusted state, and you branch on recommendedAction before you execute it. Pass the tool call as the payload and the context alongside it — the SDK encodes the request for you.
Python#
from surface import SurfaceClient, ActionContext
surface = SurfaceClient()
# `tool_call` is the JSON your agent proposed, e.g.
# {"tool": "http_request", "args": {"method": "POST", "url": "https://webhook.partner.io/sync", "body": {…}}}
context = ActionContext(
principal_domains=["acme.io"], # your own domains
allowed_egress=["api.stripe.com", "hooks.slack.com"], # hosts you legitimately call
user_request=user_message, # what the user actually asked
)
result = surface.scan_payload(tool_call, "agent-step.json", context=context)
action = result.safety_score.recommended_action
if action == "Block":
refuse("That action was blocked.") # dangerous on its face, or against policy
elif action == "Review":
escalate_to_human(tool_call) # e.g. data to a host you did not declare
else:
run(tool_call) # Allow — proceed
JavaScript#
const surface = new SurfaceClient();
const context = {
principal_domains: ["acme.io"],
allowed_egress: ["api.stripe.com", "hooks.slack.com"],
user_request: userMessage,
};
const result = await surface.scanPayload(toolCall, "agent-step.json", { context });
switch (result.safetyScore.recommendedAction) {
case "Block": return refuse("That action was blocked.");
case "Review": return escalateToHuman(toolCall);
default: return run(toolCall); // Allow
}
Go#
result, err := client.ScanPayload(ctx, toolCall, "agent-step.json", &surface.ScanFileOptions{
Context: &surface.ActionContext{
PrincipalDomains: []string{"acme.io"},
AllowedEgress: []string{"api.stripe.com", "hooks.slack.com"},
UserRequest: userMessage,
},
})
if err != nil {
return err
}
switch result.ScanResult.SafetyScore.RecommendedAction {
case "Block":
return refuse("That action was blocked.")
case "Review":
return escalateToHuman(toolCall)
default:
return run(toolCall) // Allow
}
Only the fields in Context fields have an effect. Context is optional on both scan_payload and ToolGuard: omit a field and that check stays silent. Omit context entirely and what is certainly malicious still Blocks, while irreversible actions (deleting data, granting admin, sharing publicly) are held for Review, since Surface can't see whether the user asked for them. Values that are passed are validated. Build them from your application state — never from the tool call you are scanning.
Strictness#
strictness sets how readily a judgment call becomes a verdict:
relaxed: stop only what's certainly malicious.balanced(the default): stop what's certainly malicious, and ask before risky or irreversible actions.strict: ask or stop on anything that needs judgment, including mail to personal addresses and outside recipients.
Leave it out and you get balanced, so an agent with no configuration isn't stopped while it does routine work. Set it once on the client or the guard (SurfaceClient(strictness="strict"), ToolGuard(client, strictness="strict"); strictness in the JavaScript options; the Strictness field in Go), or per call in context. Context fields has the full table.
Drop-in guard#
Writing the scan + verdict check + branch on every tool call gets repetitive. All three SDKs ship a ToolGuard that packages the propose → scan → branch pattern. Build it once with your client, then either branch on the verdict yourself or wrap a tool so it screens before it runs.
The verdicts mean: Allow, run it. Review, a person should confirm it first. Block, don't run it.
from surface import SurfaceClient, ToolGuard, ToolBlocked, ToolNeedsReview
guard = ToolGuard(SurfaceClient())
# Option A — screen and branch yourself
decision = guard.screen(call.name, call.args)
if decision.blocked:
refuse(decision.reason)
elif decision.needs_review:
ask_the_user(call, decision) # decision.findings has the offending action + evidence
else:
run(call)
# Option B — wrap the tool. It won't run on Block or Review.
safe_transfer = guard.wrap(transfer_funds)
try:
safe_transfer(to="acct_…", amount=4800)
except ToolNeedsReview as e:
ask_the_user(e.decision) # confirm, then run transfer_funds yourself
except ToolBlocked as e:
log(e.decision.reason, e.decision.findings)
When to use which. screen() (returns a Decision) when you own the agent loop and want to route Review to a human step; wrap() when a tool should hold or refuse itself. A wrapped tool holds on Review by default: ToolNeedsReview is a kind of ToolBlocked, so a handler for Block still stops it. To run Review'd calls, or to ask the user inline, set on_review (onReview in JavaScript, OnReview in Go) to "allow" or to a function that returns whether to run.
Context is optional, same as on scan_payload. Pass the fields you have from trusted app state — never from the tool arguments. A callable is only needed if the values change per call. on_decision=jsonl_trace(path) records Allow / Review / Block without logging those arguments.
from surface import ActionContext, jsonl_trace
guard = ToolGuard(
SurfaceClient(),
context=ActionContext(
principal_domains=["acme.io"],
allowed_egress=["api.stripe.com", "hooks.slack.com"],
user_request=session.user_message,
),
strictness="strict", # optional; balanced when omitted
on_decision=jsonl_trace("/var/log/surface-toolguard.jsonl"),
)
The same guard in Go and JavaScript:
guard := &surface.ToolGuard{Client: client}
// Refuse at the top of a tool's dispatch, or wrap a single-argument tool
if err := guard.Check(ctx, call.Name, call.Args); err != nil {
var nr *surface.NeedsReviewError
if errors.As(err, &nr) {
return askTheUser(call, nr) // Review: confirm first
}
return err // *surface.BlockedError on Block
}
safeTransfer := surface.Wrap(guard, "transfer", transferFunds)
const guard = new ToolGuard(client);
const d = await guard.screen(call.name, call.args);
if (d.blocked) return refuse(d.reason);
if (d.needsReview) return askTheUser(call, d);
const safeTransfer = guard.wrap(transferFunds); // throws ToolNeedsReview on Review, ToolBlocked on Block
On Pydantic AI, one line covers every tool: capabilities=[surface_hooks(guard)] from surface.pydantic_ai. It fails a Blocked call back to the model, defers a Review'd call for approval, and sends the run's prompt as user_request.
Worked examples for LangChain, OpenAI Agents, and Pydantic AI are on Agent Action Guardrails. Each SDK's guard source carries runnable versions.
When Surface is unavailable#
A scan can fail to get an answer: the network drops, a request times out, or Surface is briefly restarting. All three SDKs handle that the same way.
- One error to catch. Anything that is not a real answer from Surface raises
SurfaceUnavailableError(Python, JavaScript) or returns an error matchingsurface.ErrUnavailable(Go, viaerrors.Is): connection refused or reset, the timeout running out, an HTTP 500, 502, 503 or 504, or a response that is not Surface's JSON, such as an HTML error page from a proxy. In Python and JavaScript it is a subclass ofSurfaceError, so existing handlers still catch it; in Go,errors.Asstill finds the*SurfaceErrorfor a 5xx. Rate limits and other 4xx responses keep their usual errors. - A 60-second timeout per call, covering every retry. Change it with
timeout=(Python, seconds),timeoutMs(JavaScript) orClient.Timeout(Go); in Go a deadline on your ownctxtakes precedence. - Short retries. A 502, 503, 504 or a refused connection is retried inside the timeout, waiting for the
Retry-Afterthe server sends or backing off 1, 2, 4, 8 seconds, at most 10 times. Surface restarts its scanner when it deploys and answers 503 for a short while; a scan made then takes longer instead of failing. A request that hangs is not retried, and a 500 or a 4xx never is.
What each integration does when a scan fails:
| Integration | When Surface is unavailable |
|---|---|
ToolGuard / AsyncToolGuard, Go Wrap |
Fails closed. The tool does not run; the error reaches your code. |
screen() / scan_payload |
Raises or returns the error. Your code decides; treating it as Allow lets an unchecked action through. |
| Request middleware | Follows fail_open / failOpen / FailOpen. On by default: the request goes through unscanned. Set it off and the request is answered with 503 instead. |
| Strand's Surface node | The result's threat_level is error, which never counts as safe. |
| Contact file transfers | The transfer is refused with "scan service unavailable". |
If your agent can wait, a longer timeout rides out a slow scan. If it cannot, catch the error and ask the user, the same as a Review.
Scanning without the network#
Every client takes a local mode that points at a scanner binary on your own machine instead of the hosted API. The file is analyzed in that process: its bytes are never transmitted.
One trade worth knowing: for scripts, the hosted API sees more. It runs .js, .vbs and .wsf through a sandbox that emulates them and reports what they would fetch, run and write; the binary analyzes those statically and has no sandbox. Its string deobfuscation and macro analysis are Go-native subsets of the tools the API uses. Everything else — pattern rules, the ML models, threat feeds, behavioral analysis, and the non-malware checks — is the same engine.
Nothing about the scan is sent back either, by default. Local scans are not metered — they run on your hardware, so there is no quota to count and no reason to report them. If you want local scans in your dashboard history, pass --report-results and the scanner sends each record (name, hash, size, verdict, and the names of the findings), never the content or excerpts of it; leave it off and no filename ever leaves the machine.
The scanner needs an API key on a paid plan (it reads it from SURFACE_API_KEY; the same token you use as SURFACE_KEY works), and checks once a day that the plan is still active. If it cannot reach the server it keeps working for 72 hours on the last good answer, then stops rather than assume; a revoked key stops it immediately. The free tier is hosted-API only.
The SDK talking to that daemon needs no key of its own, because the daemon has no authentication — it listens on loopback by default for exactly that reason. Move it to a public interface only deliberately.
surface = SurfaceClient(mode="local", scanner_url="http://127.0.0.1:8080")
const surface = new SurfaceClient({ mode: "local", scannerUrl: "http://127.0.0.1:8080" });
client, err := surface.NewLocalClient(&surface.LocalConfig{})
Account calls — usage, account details, scan history — still need a key, because they read from the hosted service. Calling one without a key returns a clear authentication error rather than failing obscurely.
The SDKs don't manage scan profiles or API keys. Each key is bound to a profile and scans use it automatically. Manage profiles and keys in the dashboard, the REST API, the MCP tools, or the Tendrl CLI (tendrl-cli surface profiles|keys|history — the full programmatic account plane beyond the scan-focused SDKs).
Repositories#
| Language | Repository |
|---|---|
| Python | tendrl-inc-labs/surface-python |
| JavaScript | tendrl-inc-labs/surface-js |
| Go | tendrl-inc-labs/surface-go |
Tendrl