Docs / Surface / sdks
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.9 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 == "Block":
raise ValueError("rejected upload")
JavaScript#
const surface = new SurfaceClient();
const result = await surface.scanFile("upload.pdf");
if (result.safetyScore.recommendedAction === "Block") {
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 == "Block" {
return errors.New("rejected upload")
}
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="malicious")
except MaliciousFileError:
return error("We couldn't accept that file.")
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 — YARA, 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); leave it off and no filename ever leaves the machine.
The scanner needs its own API key (SURFACE_API_KEY) on a paid plan, 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 endpoints — usage, profiles, keys — 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.
Repositories#
| Language | Repository |
|---|---|
| Python | tendrl-inc-labs/surface-python |
| JavaScript | tendrl-inc-labs/surface-js |
| Go | tendrl-inc-labs/surface-go |
Tendrl