Custom Job Guide

Custom Job Integration Guide

This guide walks you through integrating a custom executable with KEPM as a job task — from building and deploying your binary, to authoring the job JSON, to connecting to MQTT and publishing structured logs. It covers everything a tool that runs on a schedule or at agent startup needs to work correctly in a production deployment.

If you have not read the Integrations Overview page yet, start there. It explains the key terms, how the pieces connect, and when a job task is the right pattern for your use case.

1

Build and Deploy Your Binary

What to Build

Your binary can be written in any language. The agent does not impose framework or runtime requirements — it starts your executable as a child process, tracks its exit code, and captures its output. The expected pattern is simple: start, do work, exit with a meaningful exit code.

A few things to plan from the start:

  • One build per OS. If you support Windows, Linux, and macOS, produce a separate binary for each platform. You can use one job JSON per OS or one job with multiple tasks and conditions, but separate builds per platform is simpler and easier to maintain.

  • Single purpose. Scan, report, or perform a maintenance task — then exit. Avoid designing a job task binary as a long-running daemon; that is what the plugin pattern is for.

  • Log via MQTT. Plan to publish structured log messages to the KeeperLogger MQTT topic rather than relying on stdout. Step 3 covers the full details.

Deploy the Binary on the Endpoint

Install your binary under the agent's application root. The recommended layout allows the agent to resolve your binary by name without an explicit full path:

{AgentRoot}/Jobs/bin/{CommandName}/{CommandName}.exe    (Windows)
{AgentRoot}/Jobs/bin/{CommandName}/{CommandName}        (Linux / macOS)

In the job JSON, set the task's command field to {CommandName} (no path). The job runner checks Jobs/bin/{CommandName}/ before searching PATH.

If you cannot use this layout, set executablePath to a full absolute path. You still need to set command — it is required by the job schema — but executablePath takes precedence for resolution.

Cross-Platform Jobs and osFilter

Use osFilter on the job root so each endpoint only runs the task that matches its operating system. When osFilter excludes the current OS, the validator also skips checking whether that task's binary exists on disk — which means you can deploy a Windows job to a fleet that includes Linux agents without causing validation failures.

"osFilter": {
  "windows": true,
  "linux": false,
  "macOS": false
}

For multi-OS support, the simplest approach is a separate job JSON file per platform (for example my-tool-windows.json, my-tool-linux.json), each with its own binary path and osFilter. A single job with multiple tasks and osFilter conditions on each task is also supported but is more complex to maintain.

Execution Context

Set ExecutionType on the task to control which account the agent uses when starting your process.

ExecutionType
When to use

Service

The default. Runs as the agent service account. Use this for background tasks — security scans, maintenance jobs, compliance reporters — that need machine-wide access but not an interactive user session.

User

Runs in the logged-on user's session. Use only when your tool genuinely needs user profile access interactively. Note that user-session processes are subject to additional trust checks described in the next section.

UserDesktop

Runs in the user's desktop session. Same caveats as User.

For most custom tools, Service is the right choice. If you use User or UserDesktop, read the signing and process trust section carefully — non-admin user-session processes are subject to additional restrictions.

2

Code Signing and Process Trust

This is one of the most important things to understand before you write a single line of job JSON. How the agent decides to trust your binary affects whether MQTT connections succeed and whether Plugin-tier HTTPS calls return data or 403.

How the Agent Registers Your Process

When the job runner starts your binary as a task, it adds that process to the agent's launched-process registry immediately after starting it. This registration is what grants your process:

  • Access to the local MQTT broker

  • Permission to call Plugin-tier HTTPS endpoints like /api/PluginSettings/...

Because the registration happens before your binary makes its first connection, a normally launched job task does not need to pass a certificate check to connect to MQTT or call Plugin Settings.

When Certificate Checks Run

Certificate checks run when the agent encounters a process that is not already in the launched-process registry. This happens in two cases:

  1. Your binary starts before the job runner's registration completes (a race condition on startup). The agent then falls back to a certificate-based check. The fix is a short retry loop on the first MQTT connection.

  2. Your binary is started manually — from a shell, a script, or Explorer — rather than by a job. In this case the agent must validate the executable's signature against the Keeper Privilege Manager certificate or any thumbprints listed in Settings:AlternativeSignatures. An unsigned binary fails this path.

Adding Your Certificate Thumbprint

If your binary needs to connect to MQTT or call Plugin Settings when started outside a job (for example during development or testing), add your code-signing certificate thumbprint to Settings:AlternativeSignatures in appsettings.json:

{
  "Settings": {
    "AlternativeSignatures": [
      "A1B2C3D4E5F6789012345678901234567890ABCD"
    ]
  }
}

To get the thumbprint on Windows:

Get-AuthenticodeSignature -FilePath "C:\Path\To\YourTool.exe" |
  Select-Object -ExpandProperty SignerCertificate |
  Select-Object -ExpandProperty Thumbprint

Use the thumbprint as a 40-character hex string with no spaces. You can list multiple thumbprints if you have more than one signing certificate.

Restart the Keeper Privilege Manager service after saving appsettings.json. These settings are read at startup.

AllowedNonAdminExecutables for User-Session Processes

If your binary runs in a user session (ExecutionType: User or UserDesktop) and the process owner is not an administrator, one additional check applies after the certificate check succeeds: the executable's base name (no path, no .exe) must appear in Settings:AllowedNonAdminExecutables.

{
  "Settings": {
    "AllowedNonAdminExecutables": [
      "MyTool"
    ]
  }
}

This check does not apply to Service tasks, which run under the agent service account and follow system-session trust rules. If you only run your tool as a Service job task, you do not need to configure this setting.

Recommended Practice

  1. Sign all production builds with your organization's code-signing identity (Windows Authenticode; Apple Developer ID on macOS; GPG-signed packages or IMA signing on Linux where your deployment enforces it).

    1. On Linux, the agent's process trust relies primarily on the job runner launching your binary — ensure the binary is always started via a job task rather than manually to avoid the certificate check path.

  2. Add your thumbprint to Settings:AlternativeSignatures when MQTT or Plugin Settings access must work outside a job-launched context.

  3. Keep job binaries under Jobs/, not under Plugins/. Plugin directory paths are subject to stricter validation rules that do not apply to binaries under Jobs/bin/.

  4. Always run as Service unless you have a specific reason to use a user-session execution type.

3

Author the Job JSON

The Job File and its Location

A job is a JSON file stored at:

{AgentRoot}/Jobs/{job-id}.json

The id field inside the file must exactly match the filename without the .json extension. For example, secrets-scanner.json must contain "id": "secrets-scanner".

Important: Use hyphens in job IDs, not underscores. The MQTT broker parses the job ID out of the client ID by splitting on underscores. An ID like secrets_scanner will break that parsing and can cause publish permission failures.

Last Known Good and How to Deploy Safely

In most deployments, the agent runs with Last Known Good (ConfigurationLkg) enabled. This means the agent keeps an encrypted reference copy of each job's JSON and watches the Jobs/ directory for drift from that reference. If it detects a file that doesn't match the stored copy — for example because someone edited it by hand — it restores the file from the reference copy, undoing the change.

For this reason, do not deploy jobs by dropping files directly into Jobs/. Use one of the two trusted write paths instead:

Option 1: The local HTTPS API. Post the job JSON to POST /api/Jobs on the local agent. This writes both the file and the Last Known Good reference in a single atomic operation. See Step 4 for the API details.

Option 2: A JobUpdate policy. In managed fleet environments, a JobUpdate policy delivered through the Keeper console is the standard path. The Configuration Policy Processor writes the job file and updates the reference copy as a blessed operation. Contact your Keeper administrator for the current policy schema and console workflow.

Anatomy of a Job JSON

Here is a complete, annotated example for a Windows job:

{
  "id": "secrets-scanner",
  "name": "Secrets Scanner",
  "description": "Scans for exposed credentials and publishes results to KeeperLogger.",
  "enabled": true,

  "schedule": {
    "intervalMinutes": 120
  },

  "events": [
    { "eventType": "Startup" }
  ],

  "osFilter": {
    "windows": true,
    "linux": false,
    "macOS": false
  },

  "mqttTopics": {
    "allowedPublications": ["KeeperLogger"],
    "allowedSubscriptions": []
  },

  "parameters": [],

  "tasks": [
    {
      "id": "run-scanner",
      "name": "Run scanner",
      "ExecutionType": "Service",
      "command": "SecretScanner",
      "executablePath": "C:\\Program Files\\KeeperPrivilegeManager\\Jobs\\bin\\SecretScanner\\SecretScanner.exe",
      "arguments": "--scan --keeper-api-base={KeeperApiBaseUrl}",
      "timeoutSeconds": 3600,
      "continueOnFailure": false,
      "scriptType": "Auto"
    }
  ]
}

The key fields:

id — Must match the filename. Use hyphens only; no underscores.

enabled — Set to true. A job with enabled: false will not run, even if triggered manually.

schedule — Defines the timer trigger. intervalMinutes is minutes between runs, not clock-aligned wall time. 120 means every two hours. See the schedule options section below for other modes.

events — Defines event triggers. The Startup event fires once when the agent starts, after jobs are loaded. A job can have both a schedule and events — either can trigger execution independently.

osFilter — Controls which operating systems run the job. Set only the target platform to true.

mqttTopics — Declares which MQTT topics your task may publish to and subscribe from. The broker enforces this list. If KeeperLogger is missing here, your process's publish calls will be denied even if the MQTT connection succeeds.

tasks — The array of steps to run. For most custom tools, one task is enough. Tasks run sequentially.

arguments — Supports variable substitution. {KeeperApiBaseUrl} is replaced by the agent with the local HTTPS API base URL (for example https://127.0.0.1:6889). Your binary parses this flag and uses it to call Plugin Settings at runtime. See the code example in the Overview page.

Schedule Options

The job supports four scheduling modes. Only one mode may be active per job.

Interval — Runs every N minutes from when the scheduler starts:

"schedule": { "intervalMinutes": 120 }

Cron — Standard five-field cron expression (minute, hour, day-of-month, month, day-of-week):

"schedule": { "cronExpression": "0 3 * * *" }

One-time — Runs once at a specific UTC time:

"schedule": { "runAt": "2025-06-01T02:00:00Z" }

Calendar — Runs at specific times on specific days of the week or month:

"schedule": {
  "calendar": [
    { "time": "03:00", "daysOfWeek": ["Monday", "Wednesday", "Friday"] }
  ]
}

Startup vs. Interval Timing

A job with both a Startup event and an intervalMinutes schedule will run at startup (once, when the agent starts) and then again on the interval from that point forward. The interval timer runs independently — do not assume a fixed relationship between the Startup run and the first interval run without testing on your target agent version.

When the agent starts, Startup jobs are triggered after jobs are loaded and after KeeperLogger is ready to receive messages. The interval timer's first tick occurs after the configured number of minutes from when the scheduler initializes — not from when the Startup run completes.

4

Deploy the Job

Using the Local HTTPS API

The agent exposes a REST API on the loopback interface for managing jobs. The HTTPS listener runs on Settings:KestrelHttpsPort (commonly 6889). All job management endpoints require Admin-tier authorization — your deployment team will provide the authentication method (typically mutual TLS with client certificates provisioned for your environment).

To create or update a job:

Invoke-RestMethod -Method Post `
  -Uri "https://127.0.0.1:6889/api/Jobs" `
  -ContentType "application/json" `
  -Certificate $adminClientCert `
  -Body (Get-Content -Raw .\secrets-scanner.json)
curl -s -X POST https://127.0.0.1:6889/api/Jobs \
  --cert /path/to/client.pem \
  --key /path/to/client.key \
  --cacert /path/to/ca.pem \
  -H "Content-Type: application/json" \
  -d @secrets-scanner.json

The key job management endpoints:

Method
Path
Purpose

POST

/api/Jobs

Create a job

PUT

/api/Jobs/{jobId}

Replace an existing job

DELETE

/api/Jobs/{jobId}

Remove a job

POST

/api/Jobs/validate

Validate JSON without saving

GET

/api/Jobs

List all jobs

GET

/api/Jobs/{jobId}

Get one job definition

POST

/api/Jobs/{jobId}/trigger

Trigger a run manually

For the full API reference including Plugin Settings endpoints and authorization details, see the HTTP Reference.

Validation Requires Binaries to be Present

POST /api/Jobs and POST /api/Jobs/validate run the job validator, which checks that your task's executable exists on disk at the time of the call. This means you must deploy the binary before registering the job via the API, or validate on a machine that already has the binary at the expected path.

If the job's osFilter excludes the current operating system, binary existence checks are skipped for that validation run. This lets you register a Linux-only job from a Windows host.

Using a JobUpdate Policy

In managed fleet environments, the typical deployment path is a JobUpdate policy delivered through the Keeper console. Your Keeper administrator handles this — you provide the job JSON, and they configure the policy envelope and push it to endpoints. Contact your administrator for the current policy schema and console steps. See also: Create, Modify, or Delete Job

5

Connect to MQTT and Publish Logs

Once your binary is deployed and the job is registered, you need to wire up MQTT so log messages flow through the agent's logging pipeline.

Step 1: Declare MQTT Permissions on the Job

Add mqttTopics to the job root object. The broker enforces this list — if a topic is missing, publishes to it will be denied even if the MQTT connection itself succeeds.

"mqttTopics": {
  "allowedPublications": ["KeeperLogger"],
  "allowedSubscriptions": []
}

If your tool also publishes to a custom event topic (see Job progress over MQTT), add that topic to allowedPublications as well.

allowedSubscriptions is only needed if your tool subscribes to MQTT topics. Most job task binaries do not.

Step 2: Read the Broker Address from Plugin Settings

When the job defines mqttTopics with at least one entry, the agent sets two environment variables before starting your task:

Variable
Value

KEEPER_JOB_ID

The id field from your job JSON

KEEPER_JOB_NAME

The name field from your job JSON

Your binary needs KEEPER_JOB_ID to form the MQTT client ID. It needs the broker address from Plugin Settings. Both happen at startup:

import os
import argparse
import json
import ssl
import urllib.request

FALLBACK_HOST = "127.0.0.1"
FALLBACK_PORT = 8675

def load_broker_settings(keeper_api_base: str) -> tuple[str, int]:
    url = f"{keeper_api_base}/api/PluginSettings/KeeperPrivilegeManager"
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE  # self-signed on loopback; use CA bundle if provided
    try:
        with urllib.request.urlopen(url, context=ctx, timeout=10) as resp:
            settings = json.loads(resp.read().decode())
        return settings.get("broker.host", FALLBACK_HOST), int(settings.get("broker.port", FALLBACK_PORT))
    except Exception as exc:
        print(f"[WARNING] Plugin Settings unavailable ({exc}). Using defaults.")
        return FALLBACK_HOST, FALLBACK_PORT

parser = argparse.ArgumentParser()
parser.add_argument("--keeper-api-base", required=True)
args = parser.parse_args()

job_id   = os.environ.get("KEEPER_JOB_ID", "")
job_name = os.environ.get("KEEPER_JOB_NAME", "")
broker_host, broker_port = load_broker_settings(args.keeper_api_base)

A few things to remember here:

  • This call requires Plugin-tier auth. It only succeeds from a process the agent launched. A manually started copy of your binary will get 403.

  • The agent uses a self-signed TLS certificate on the loopback interface. If your organization provides a CA bundle for the agent's certificate, configure your HTTP client to use it. Otherwise, disable certificate verification for loopback-only connections per your security policy.

  • Always fall back to defaults on failure, but log a warning. A failed Plugin Settings call in production is a configuration problem operators need to see.

Step 3: Form the MQTT Client ID

The broker validates your client ID against the job it's associated with. For job tasks, the format is:

{KEEPER_JOB_ID}_{ExecutableToken}_{ProcessId}

Where:

  • {KEEPER_JOB_ID} is the value from the environment variable the agent set

  • {ExecutableToken} is a short, stable name for your binary (no path characters, no spaces, no underscores)

  • {ProcessId} is the current OS process ID as a decimal integer

import os

job_id = os.environ.get("KEEPER_JOB_ID", "unknown")
pid    = os.getpid()
client_id = f"{job_id}_SecretScanner_{pid}"
# Example result: "secrets-scanner_SecretScanner_48292"

Do not use underscores in the job ID. The broker splits the client ID on _ to extract the job ID segment. If your job ID contains underscores — for example secrets_scanner — the broker will mis-parse it, fail to associate the connection with the right job, and publish permission checks will fail. Use hyphens: secrets-scanner.

Step 4: Connect to the MQTT Broker

Connect using TLS to the host and port you retrieved from Plugin Settings. The broker requires an encrypted connection — plain TCP is not accepted.

import ssl
import paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        print("Connected to MQTT broker")
    else:
        print(f"MQTT connection failed: {reason_code}")

mqttc = mqtt.Client(
    mqtt.CallbackAPIVersion.VERSION2,
    client_id=client_id,
    protocol=mqtt.MQTTv5
)

# TLS configuration — use CA bundle if provided, otherwise trust loopback cert
tls_ctx = ssl.create_default_context()
tls_ctx.check_hostname = False
tls_ctx.verify_mode = ssl.CERT_NONE  # replace with CA bundle if available
mqttc.tls_set_context(tls_ctx)

mqttc.on_connect = on_connect
mqttc.connect(broker_host, broker_port, keepalive=60)
mqttc.loop_start()

Note on timing: If your binary connects to MQTT immediately on startup and the connection is refused, it is likely a race between your connection attempt and the agent's process registration completing. Add a short retry loop — one or two seconds — before treating the connection as failed.

Prefer MQTT v5 when your client library supports it. The agent's internal components use MQTT v5.

Step 5: Publish Log Messages to KeeperLogger

Publish to the topic KeeperLogger with QoS 1 and retain: false. Each publish must be a single JSON object in the following shape — property names are case-sensitive:

{
  "Id": "b2e7f4a8-6c2d-4b1e-9f3a-8c1d2e3f4a5b",
  "Version": 1,
  "RespondToTopic": null,
  "MetaData": {
    "MessageType": 4,
    "LogLevel": 1,
    "Source": "SecretScanner",
    "Category": "SecretsScan",
    "Message": "Scan complete: 0 issues detected.",
    "CorrelationId": "",
    "Context": ""
  }
}

Field Reference:

Field
Value

Id

A new random UUID string for each message

Version

1

RespondToTopic

null

MetaData.MessageType

4 (or the string "Log")

MetaData.LogLevel

0 Debug, 1 Info, 2 Warning, 3 Error, 4 Critical, 5 Verbose — or the string equivalents

MetaData.Source

Your tool's name, shown in logs

MetaData.Category

A short label for the phase or component

MetaData.Message

Human-readable text — must be non-empty. Do not include raw secret values; log counts and paths instead

MetaData.CorrelationId

Optional trace ID; use "" if unused

MetaData.Context

Optional extra text; use "" if unused

A complete publish example in Python:

import uuid
import json

def publish_log(client, source: str, category: str, message: str, level: int = 1):
    payload = {
        "Id": str(uuid.uuid4()),
        "Version": 1,
        "RespondToTopic": None,
        "MetaData": {
            "MessageType": 4,
            "LogLevel": level,
            "Source": source,
            "Category": category,
            "Message": message,
            "CorrelationId": "",
            "Context": ""
        }
    }
    client.publish("KeeperLogger", json.dumps(payload), qos=1, retain=False)

# Usage
publish_log(mqttc, "SecretScanner", "SecretsScan", "Scan complete: 0 issues detected.")
publish_log(mqttc, "SecretScanner", "SecretsScan", "3 files could not be read.", level=2)

A few things to keep in mind:

  • Message must be non-empty. The logger rejects empty messages.

  • Do not include raw secrets in log messages. Log counts, file paths, and status — never the credential values themselves.

  • Avoid raw newlines inside Message and Context. Replace them with spaces if you are logging scraped file content.

Optional: Job Progress Over MQTT

If you need downstream subscribers to observe job execution events — separate from the structured log messages going to KeeperLogger — you can publish to the job's event topic.

The default event topic pattern is Jobs/{jobId}/events. You can also set a custom topic with the eventTopic field on the job root:

{
  "id": "secrets-scanner",
  "eventTopic": "Jobs/secrets-scanner/events",
  "mqttTopics": {
    "allowedPublications": [
      "KeeperLogger",
      "Jobs/secrets-scanner/events"
    ],
    "allowedSubscriptions": []
  }
}

The event topic must also appear in allowedPublications. If it is missing from that list, publish calls to it will be denied even though the connection succeeds. When you add an eventTopic, always verify that both eventTopic and allowedPublications contain the same topic string.

Pre-Launch Checklist

Run through every item on a pilot endpoint before rolling out to your fleet.

#
Check

1

Binary exists at the resolved command / executablePath on the endpoint before POST /api/Jobs is called

2

Job id matches filename; no underscores in the id

3

enabled is true

4

osFilter matches the target platform

5

mqttTopics.allowedPublications includes KeeperLogger (and the eventTopic if used)

6

Job appears in GET /api/Jobs after deployment

7

POST /api/Jobs/{jobId}/trigger runs the task successfully (Admin auth required)

8

Running task receives KEEPER_JOB_ID and KEEPER_JOB_NAME environment variables

9

GET /api/PluginSettings/KeeperPrivilegeManager returns broker.host and broker.port from inside the running task

10

MQTT connects with TLS and the correct {jobId}_Token_{pid} client ID format

11

Publish to KeeperLogger succeeds and log messages appear in the operator's log view

12

If using eventTopic: publish to that topic succeeds

13

Code signing: production binary is signed; thumbprint is in AlternativeSignatures if needed

Troubleshooting

Symptom
Where to look

POST /api/Jobs returns 403

Admin auth — wrong certificate or the calling process is not admin-authorized

POST /api/Jobs returns 400 with validation errors

Binary not found at the path on the validating host; deploy binary first

Job appears in GET /api/Jobs but does not run

Check enabled: true; check osFilter matches the endpoint OS

Hand-edited Jobs/*.json is reverted

Last Known Good is enabled — use the API or JobUpdate policy instead

GET /api/PluginSettings/... returns 403 from inside the task

Process not registered as trusted; ensure it is started by the job runner, not manually

MQTT connection refused

Process not in launched-process registry; add retry logic on startup

MQTT connects but publish is denied

Topic missing from mqttTopics.allowedPublications in the job JSON

Client ID parsing errors / wrong job association

Underscores in job id — switch to hyphens

Log messages not appearing in operator view

KeeperLogger may not be running or subscribed; confirm with your administrator

Last updated

Was this helpful?