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.

bash

uv pip install "git+https://github.com/tendrl-inc-labs/surface-python"
bash

pip install "git+https://github.com/tendrl-inc-labs/surface-python"

JavaScript / TypeScript#

bash

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#

bash

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:

bash

uv pip install "git+https://github.com/tendrl-inc-labs/[email protected]"
npm install github:tendrl-inc-labs/surface-js#v0.1.0
go get github.com/tendrl-inc-labs/[email protected]

Authenticate#

All three fall back to the SURFACE_KEY environment variable, so the key does not need to appear in code:

bash

export SURFACE_KEY="<token-from-Access-Control>"

See Getting Started for creating one.

Scan a file#

Python#

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#

javascript


const surface = new SurfaceClient();

const result = await surface.scanFile("upload.pdf");
if (result.safetyScore.recommendedAction === "Block") {
  throw new Error("rejected upload");
}

Go#

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.

python

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:

python

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.

python

surface = SurfaceClient(mode="local", scanner_url="http://127.0.0.1:8080")
javascript

const surface = new SurfaceClient({ mode: "local", scannerUrl: "http://127.0.0.1:8080" });
go

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#