Skip to main content

Async Python client for the PreClick URL security scanning service. Assess target URLs for potential threats and alignment with the user's browsing intent before navigation.

Project description

preclick

Async Python client for the PreClick URL security scanning service. Assess target URLs for potential threats and alignment with the user's browsing intent before navigation.

Publisher: CybrLab.ai | Service: PreClick

Scan-oriented public API: two primary methods (scan, scan_with_intent) plus five job methods for explicit control. No protocol vocabulary. No connect() required. No polling boilerplate for the common case.

Trial mode currently allows up to 100 requests per day with no API key and no sign-up required. Trial limits, availability, and higher-limit access are subject to change and may be governed by separate hosted-service terms. For higher limits, configure an API key (see Configuration).


Install

pip install preclick

Requires Python >= 3.10.

Quick start

import asyncio
from preclick import PreClickClient

async def main():
    async with PreClickClient(
        api_key="sk-...",  # optional; trial mode if omitted
    ) as client:
        result = await client.scan("https://example.com")

        print(result["agent_access_directive"])  # ALLOW | DENY | RETRY_LATER | REQUIRE_CREDENTIALS
        print(result["agent_access_reason"])

asyncio.run(main())

That's it. The first call auto-connects to the PreClick service and returns the scan result as soon as it's ready (typically 70--80 seconds). The async with block cleans up when you're done.

See examples/ for runnable scripts: basic_scan.py, intent_scan.py, long_running_scan.py, and manual_polling.py.

Hosted service and data sent

This package is open-source client software for the hosted PreClick service. No scanner logic runs locally when using the default endpoint.

Calls to scan() / start_scan() send the target URL to https://preclick.ai/mcp. Calls to scan_with_intent() / start_scan_with_intent() send both the target URL and the user intent text. If configured, the API key is sent as an X-API-Key header, along with any custom headers passed to the client.

Do not submit secrets, credentials, highly sensitive personal data, regulated data, private/internal URLs, or confidential business information unless you are authorized to do so and have reviewed the applicable service, privacy, retention, and acceptable-use terms for your use case. For hosted-service terms or privacy questions, contact contact@cybrlab.ai.

Intent-aware scanning

When the user has stated their purpose (login, purchase, download, booking, etc.), use scan_with_intent so the scanner can evaluate destination alignment in addition to threat signals:

result = await client.scan_with_intent(
    "https://example.com",
    "log in to my bank",
)

print(result["agent_access_directive"])
print(result["intent_alignment"])
# misaligned | no_mismatch_detected | inconclusive | not_provided

Interpreting results

Every completed scan returns a dict with these fields:

Field Type Description
risk_score float (0.0--1.0) Threat probability
confidence float (0.0--1.0) Analysis confidence
analysis_complete bool Whether the analysis finished fully
agent_access_directive str ALLOW, DENY, RETRY_LATER, or REQUIRE_CREDENTIALS
agent_access_reason str Normalized reason code for the directive
intent_alignment str misaligned, no_mismatch_detected, inconclusive, or not_provided

Use agent_access_directive for navigation decisions:

  • ALLOW -- No blocking signal was detected. Continue only according to user confirmation, local policy, and normal security controls.
  • DENY -- Do not navigate. Check agent_access_reason for the cause.
  • RETRY_LATER -- Verification could not complete (temporary issue). Retry.
  • REQUIRE_CREDENTIALS -- The target requires authentication. Ask the user how to proceed.

Scans typically take around 70--80 seconds on current production traffic. Both scan() and scan_with_intent() handle the polling internally; the default wait window is 10 minutes. Pass max_wait to override:

await client.scan("https://example.com", max_wait=120)  # 2 minutes

Timeouts and cancellation

Use max_wait to limit how long scan() / scan_with_intent() / wait_for_scan() will wait for a result. When the deadline is reached, a PreClickError is raised with err.scan_id attached so the already-submitted scan is never orphaned:

from preclick import PreClickClient, PreClickError

async def scan_with_timeout():
    async with PreClickClient() as client:
        try:
            result = await client.scan(
                "https://example.com",
                max_wait=30,  # give up after 30s
            )
        except PreClickError as err:
            scan_id = getattr(err, "scan_id", None)
            if scan_id:
                # Resume later without re-submitting
                result = await client.wait_for_scan(scan_id)

Standard asyncio task cancellation (task.cancel()) also works. After a scan is submitted, any error raised by the client -- timeout, cancellation, transport failure -- carries err.scan_id so callers can resume with wait_for_scan(scan_id).

Note: Wrapping a scan call in asyncio.wait_for() works for cancellation, but the TimeoutError it raises does not carry scan_id. Prefer max_wait when you need to recover the scan ID on timeout.

All scan and job methods also accept a signal keyword for cooperative cancellation. Pass an asyncio.Event; if it is set before submission, no request is sent. If it is set while waiting, asyncio.CancelledError is raised and carries err.scan_id once a scan ID is known:

import asyncio

cancel = asyncio.Event()

try:
    result = await client.scan("https://example.com", signal=cancel)
except asyncio.CancelledError as err:
    if getattr(err, "scan_id", None):
        result = await client.wait_for_scan(err.scan_id)

Long-running scans (explicit control)

For the common case, scan() / scan_with_intent() is all you need. Use the job methods below when you need explicit control over the submission/wait lifecycle -- for example, reporting progress to a UI, integrating with an existing job queue, or persisting a scan ID across processes or workers.

Submit once, wait later

submission = await client.start_scan("https://example.com")
# ... hand submission["scan_id"] off to another function/worker/process ...
result = await client.wait_for_scan(submission["scan_id"])
print(result["agent_access_directive"])

scan(url) is literally start_scan(url) followed by wait_for_scan(scan_id) -- splitting it lets you control the two steps independently.

Submit and poll manually

If you need a progress UI or you can't block on a single call, use get_scan_result() in your own loop:

import asyncio

submission = await client.start_scan("https://example.com")

while True:
    envelope = await client.get_scan_result(submission["scan_id"])

    if envelope["status"] == "completed":
        if not envelope.get("result"):
            raise RuntimeError("scan completed but result is missing")
        print(envelope["result"]["agent_access_directive"])
        break

    if envelope["status"] != "working":
        raise RuntimeError(f"scan ended with status: {envelope['status']}")

    # Respect the server's recommended poll interval
    hint = envelope.get("retry_after_ms")
    wait_secs = hint / 1000.0 if isinstance(hint, (int, float)) and hint > 0 else 2.0
    await asyncio.sleep(wait_secs)

get_scan_status(scan_id) is the cheaper status-only variant -- it returns the current status without the result payload.

API reference

Primary scan API (convenience wrappers)

# Scan a URL and return the result. Handles submission + polling internally.
await client.scan(url)
await client.scan(url, max_wait=120)
await client.scan(url, signal=cancel_event)

# Intent-aware variant. Use when the user has stated their purpose.
await client.scan_with_intent(url, intent)
await client.scan_with_intent(url, intent, max_wait=120)
await client.scan_with_intent(url, intent, signal=cancel_event)

Both return the inner scan result dict directly (see Return shapes below).

Job methods (explicit control)

# Submit a URL for scanning. Returns a submission envelope with scan_id.
await client.start_scan(url)
await client.start_scan(url, signal=cancel_event)

# Submit with user intent.
await client.start_scan_with_intent(url, intent)
await client.start_scan_with_intent(url, intent, signal=cancel_event)

# Non-blocking status check for a scan.
await client.get_scan_status(scan_id)
await client.get_scan_status(scan_id, signal=cancel_event)

# Non-blocking result fetch. Returns { status, result, retry_after_ms, ... }.
await client.get_scan_result(scan_id)
await client.get_scan_result(scan_id, signal=cancel_event)

# Wait for a scan to complete and return the inner scan result payload.
await client.wait_for_scan(scan_id)
await client.wait_for_scan(scan_id, max_wait=600)
await client.wait_for_scan(scan_id, signal=cancel_event)

Lifecycle

await client.close()          # clean up (alias for disconnect)
client.is_connected            # bool
await client.connect()         # optional; first scan call auto-connects
await client.disconnect()      # same as close(); close() is preferred

# Context manager (recommended)
async with PreClickClient() as client:
    ...

Return shapes

scan / scan_with_intent / wait_for_scan

All three return the inner scan result directly:

{
    "risk_score": 0.15,          # float 0.0--1.0
    "confidence": 0.92,          # float 0.0--1.0
    "analysis_complete": True,
    "agent_access_directive": "ALLOW",  # ALLOW | DENY | RETRY_LATER | REQUIRE_CREDENTIALS
    "agent_access_reason": "no_immediate_risk_detected",
    "intent_alignment": "not_provided",  # misaligned | no_mismatch_detected | inconclusive | not_provided
}

start_scan / start_scan_with_intent

{
    "scan_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "working",
    "status_message": "Queued for processing",
    "created_at": "2026-01-18T12:00:00Z",
    "updated_at": "2026-01-18T12:00:00Z",
    "ttl_ms": 720000,
    "poll_interval_ms": 2000,
    "message": "Scan submitted.",
}

get_scan_status

{
    "scan_id": "...",
    "status": "working",  # working | completed | failed | cancelled
    "status_message": "...",
    "created_at": "...",
    "updated_at": "...",
    "ttl_ms": 720000,
    "poll_interval_ms": 2000,
}

get_scan_result (still running)

{
    "scan_id": "...",
    "status": "working",
    "status_message": "...",
    "result": None,
    "retry_after_ms": 2000,
    "message": "Scan still in progress.",
}

get_scan_result (completed)

{
    "scan_id": "...",
    "status": "completed",
    "status_message": "Scan completed successfully",
    "result": {
        "risk_score": 0.15,
        "confidence": 0.92,
        "analysis_complete": True,
        "agent_access_directive": "ALLOW",
        "agent_access_reason": "no_immediate_risk_detected",
        "intent_alignment": "not_provided",
    },
    "retry_after_ms": None,
    "message": "Scan completed successfully.",
}

Configuration

PreClickClient(**kwargs) accepts:

Option Type Default Description
api_key str None PreClick API key. Sent as X-API-Key. Trial mode (100 req/day) if omitted. Must be non-empty when provided.
endpoint str https://preclick.ai/mcp Override the PreClick endpoint URL.
client_name str preclick-mcp-client-python Reported client name.
client_version str package version Reported client version. Defaults to the installed package version.
headers dict {} Extra HTTP headers to send with every request. Values must be strings.
request_timeout float 600 Per-request timeout in seconds. Must be a positive number.

To obtain an API key for higher limits, contact contact@cybrlab.ai.

Errors

The client raises three error types (all subclasses of PreClickError):

from preclick import (
    PreClickError,
    PreClickConnectionError,
    PreClickRemoteError,
)

try:
    result = await client.scan("https://example.com")
except asyncio.CancelledError as err:
    # Caller cancelled the task. If err.scan_id is present, submission
    # had already completed and you can resume with
    # client.wait_for_scan(err.scan_id).
    pass
except PreClickConnectionError as err:
    # Transport / connection failure (network, DNS, TLS, handshake).
    # Check err.retryable -- False for malformed-endpoint config errors,
    # True for transient sockets/DNS/TLS failures.
    if err.retryable:
        pass  # safe to retry with backoff
except PreClickRemoteError as err:
    # Remote-side failure: scan failed / cancelled / expired, rate limit,
    # auth failure, etc. Inspect err.code and err.data for context.
    print(err.code, err.data)
except PreClickError as err:
    # Misuse (invalid arguments) or wait_for_scan max_wait exhausted.
    pass

Troubleshooting

Symptom Cause Fix
PreClickConnectionError: Failed to connect ... Endpoint unreachable Check network; verify curl -I https://preclick.ai/mcp returns a response
PreClickConnectionError: Invalid PreClick endpoint ... Invalid endpoint option Pass a valid https:// URL as the endpoint option
PreClickRemoteError with code 401 API key required or invalid Set a valid api_key in client options
PreClickRemoteError with code 429 Rate limit exceeded Reduce frequency or add an API key for higher limits
PreClickError: wait_for_scan exhausted ... Scan did not complete within window Pass a larger max_wait to scan / scan_with_intent / wait_for_scan, or use manual polling
Scan takes a long time Target site is slow or complex Wait for completion; scans typically take 70--80 seconds

Bundled agent skill

skills/preclick/SKILL.md is a generic agent skill that instructs LLM agents on when to call scan vs scan_with_intent, and how to interpret the response. Drop it into any agent runtime that loads markdown skills to give the agent automatic preflight URL verification behavior.

Important notice

This package is client software licensed under the Apache License 2.0. Use of the hosted PreClick service may be governed by separate service terms, acceptable-use rules, rate limits, privacy commitments, and law. Do not use the hosted service for unauthorized, unlawful, abusive, or malicious activity.

Before using the hosted service with production, regulated, personal, confidential, or private/internal data, make sure you are authorized to submit that data and have reviewed the applicable service, privacy, retention, and acceptable-use terms. For hosted-service terms or privacy questions, contact contact@cybrlab.ai.

Scan results are informational risk signals, not a guarantee that a URL is safe or unsafe. They are not a substitute for user judgment, browser and endpoint security controls, organizational security review, or legal and compliance review.

Support

License

Copyright 2026 CybrLab.ai.

Licensed under the Apache License 2.0. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

preclick-0.1.0.tar.gz (35.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

preclick-0.1.0-py3-none-any.whl (23.5 kB view details)

Uploaded Python 3

File details

Details for the file preclick-0.1.0.tar.gz.

File metadata

  • Download URL: preclick-0.1.0.tar.gz
  • Upload date:
  • Size: 35.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for preclick-0.1.0.tar.gz
Algorithm Hash digest
SHA256 4bd312acc112320581882a418a841adda66ecd597c481b1f98fc989f7901fbfe
MD5 3d015d1cb8f5bd0cb0aa418e9e3bb3aa
BLAKE2b-256 153d82e0fc9a6a1e5f04ea33d57aacaf9298adbe0e296f9ebfcc6017ed2ec2a7

See more details on using hashes here.

File details

Details for the file preclick-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: preclick-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 23.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.2

File hashes

Hashes for preclick-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d0d7ccae4219180635400f1da35c780b0efce85cd7a2d65ca35ccfc09e2bd917
MD5 85463f4949946d1782776648b3003e91
BLAKE2b-256 4b3c2455fca5c0934c1aa1fc14e930183a5456b281b30c90a8b691bb90555c77

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page