Connectors

client.connectors (ConnectorsAPI) wraps FortiSOAR’s /api/integration surface – discovery, healthcheck, configuration, operation execution, the Connector Studio dev workspace, and install/uninstall. client.agents (AgentsAPI) covers the remote execution agent side: pushing, upgrading, and removing a connector on an agent, plus a liveness heartbeat.

A complete, runnable walkthrough lives in examples/manage_connectors.py – it defaults to read-only and exercises every method below.

Discovery & health

>>> client = demo_client()
>>> conn = client.connectors
>>> installed = conn.list_configured()           # installed + configured connectors
>>> [c.name for c in installed[:3]]
['smtp', 'code-snippet', ...]
>>> conn.resolve_version("mitre-attack")         # the configured version (None if absent)
'2.0.2'
>>> conn.resolve_version("not-installed") is None
True
>>> conn.configurations("mitre-attack")          # [{config_id, name, default}]
[ConnectorConfigSummary(id=7, config_id='01e4e6b4-c34e-4fc1-b692-bb08591f1fe5', name='Demo', default=True)]
>>> hc = conn.healthcheck("mitre-attack")        # status="Available" is green
>>> (hc.status, hc.name, hc.version)
('Available', 'mitre-attack', '2.0.2')

connector_detail fetches a connector’s full record – its operations (each with parameters + output_schema) and configurations. Captured live and trimmed to a doctest-friendly slice (the config dict on each configuration is dropped – it carries connection details):

>>> detail = conn.connector_detail("smtp")
>>> (detail["name"], detail["version"], detail["config_count"])
('smtp', '2.6.0', 1)
>>> [o["operation"] for o in detail["operations"][:3]]
['send_email_new', ...]
>>> [c["name"] for c in detail["configuration"]]
['localhost-postfix']

Executing an operation

execute() returns a typed ExecuteResult.ok is the status == "Success" check, .data is the connector’s own output (shape varies by connector/operation). Live-verified against cisa-advisory’s get_known_exploited_vulnerability_cves – a public, read-only, parameter-less feed lookup safe to demo against a real vendor connector (the only side effect is CISA’s public catalog serving one GET):

>>> result = conn.execute("cisa-advisory", "get_known_exploited_vulnerability_cves")
>>> result.ok
True
>>> result.data["title"]
'CISA Catalog of Known Exploited Vulnerabilities'
>>> result.data["vulnerabilities"][0]["cveID"]
'CVE-2026-45659'

⚠️ For an agent-bound connector (see the module warning), execute() is fire-and-forget – it returns immediately with an in-progress status and empty data; the real result is pushed over a websocket, not pollable here.

Dynamic operation parameters (apiOperation)

A select/multiselect parameter can declare apiOperation – the name of a sibling connector operation whose result populates the dropdown at render time. This lets an operation’s choices come from the live remote system (VMs, severities, locations, …) instead of a hardcoded options list. The populating operation is visible: false (hidden from the playbook palette) and receives the connector config so it can authenticate.

action_ui_schema() returns the params with their apiOperation and apiOnchange fields, so a UI or agent can detect which params are dynamic:

params = conn.action_ui_schema("cisco-threatgrid", "submit_sample")
for p in params:
    if p.apiOperation:
        print(f"{p.name}: type={p.type} -> call {p.apiOperation}")
    else:
        print(f"{p.name}: type={p.type} (static)")

To resolve the choices for a dynamic param, call the populating operation via execute() – the result is a plain string list or [{"title": "...", "value": "..."}] objects:

defn = conn.definition("cisco-threatgrid")
op = next(o for o in defn.operations if o.operation == "submit_sample")
vm_param = next(p for p in op.parameters if p.apiOperation == "get_available_vms")

result = conn.execute("cisco-threatgrid", vm_param.apiOperation, config="<config-uuid>")
choices = result.data  # ["Windows 7 64-bit", "Linux 64-bit", ...]

When apiOnchange=True, the populating operation also receives the current values of all sibling parameters in params (for cascading dropdowns like sap-rfc’s pick-a-module-then-its-params-appear). Pass them as the params argument to execute:

result = conn.execute(
    "sap-rfc", "get_rfc_function_params",
    config="<config-uuid>",
    params={"function_name": "RFC_READ_TABLE"},  # sibling value
)
# result.data = {"options": "RFC_READ_TABLE", "onchange": {"RFC_READ_TABLE": [param, ...]}}

See the Connector Building Guide (section “Dynamic Options from an Operation”) for the full info.json declaration + Python handler patterns.

Creating, rotating, and deleting a configuration

create_configuration/update_configuration/delete_configuration write credentials via POST/PUT/DELETE /api/integration/configuration/. Captured live against a throwaway virustotal config (api_key is a placeholder value, never a real credential) – created, rotated, then deleted, leaving the box with 0 virustotal configs afterwards, same as before:

>>> created = conn.create_configuration(
...     "virustotal",
...     {"server": "www.virustotal.com", "api_key": "test-doctest-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,   # skip the schema fetch (config_schema) for this offline demo
...     autofill=False,
... )
>>> (created.name, created.config["server"])
('pyfsr-doctest-config', 'www.virustotal.com')

Note config["api_key"] comes back as the literal string "NULL" regardless of what was sent – the server never echoes a stored secret, only this sentinel:

>>> created.config["api_key"]
'NULL'

update_configuration sends the config whole – include every field, not just the one you’re rotating:

>>> updated = conn.update_configuration(
...     "virustotal", created.config_id,
...     {"server": "www.virustotal.com", "api_key": "test-rotated-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,
...     autofill=False,
... )
>>> updated.name
'pyfsr-doctest-config'

Note

The PUT response omits connector_name/connector_version – present on create_configuration’s response, absent on update_configuration’s. Don’t rely on either field being there after an update.

>>> conn.delete_configuration(created.config_id) is None
True

Making a configuration the default

A connector whose configuration isn’t marked default fails its healthcheck with Could not find a configuration matching the id get_default_config or the default configuration – the config is there and usable by name, but anything resolving by default gets nothing.

There is no flag-only route: PUT /api/integration/configuration/{config_id}/ replaces the whole record. That makes the obvious fix dangerous, because the listing returns config: null while only the single-record GET carries the real field map – build the PUT body from the listing and you wipe the credentials. set_default_configuration does the read-then-echo for you:

conn.set_default_configuration("fortigate-firewall")             # the only config
conn.set_default_configuration("fortigate-firewall", "a5fb56f2") # by config_id
conn.set_default_configuration("fortigate-firewall", name="fortigate-lab")

It deliberately skips validate/autofill – the stored config is already what the appliance accepted, and materializing it against the schema would rewrite fields this call has no business touching. A remote-agent binding is carried over explicitly; omitting agent on the PUT silently moves execution back to the self-agent. If the appliance returns the "NULL" secret sentinel instead of a stored value, the call raises rather than writing that sentinel over a live credential.

Note

The stored ciphertext for a secret legitimately changes across this call – the appliance re-encrypts on save while the plaintext does not change. Verified on a live 8.0.0 appliance: a FortiGate configuration that could not be health-checked reported Available afterwards, with the upstream still reachable. Don’t read the changed value as corruption.

Warning

The trailing slash on /api/integration/configuration/{config_id}/ is mandatory. Without it the gateway rejects the call with 403 Could not validate HMAC fingerprint, which reads like a permissions or auth problem rather than a URL typo.

Connector Studio dev workspace

Edit a checked-out connector’s source, then publish it onto the running appliance – the same flow as the in-product Studio editor.

dev = conn.dev_list()                       # connectors checked out for editing
entity_id = dev[0]["id"]

conn.dev_edit(entity_id)                     # open for editing (Studio "Edit")
conn.dev_read_file(entity_id, "/hello-world_1_0_0_dev/info.json")
conn.dev_write_file(entity_id, {"path": "info.json", "content": "{...}"})
conn.dev_publish(entity_id, replace=True)    # land changes + refresh integrations

Note

dev_publish() is also the supported escape hatch when a same-version .tgz upload left stale code cached in the integrations service – it triggers a service refresh the standard $replace=true install path does not.

Install / uninstall

# Appliance (self-agent):
conn.install("fortinet-fortisiem", "6.1.0", wait=True)   # by name from Content Hub
conn.install_from_file("hello-world-1.0.0.tgz", replace=True)  # upload a .tgz bundle
conn.uninstall("fortinet-fortisiem")

# Remote agent:
client.agents.install_connector(agent_id, name="cyops_utilities", version="3.7.1")
client.agents.upgrade_connector(agent_id, name="cyops_utilities", version="3.8.0")
client.agents.uninstall_connector(agent_id, name="cyops_utilities", version="3.8.0")

client.agents.heartbeat(agent_id)            # liveness over the secure-message bus

Warning

Appliance uninstall (uninstall()) and agent uninstall (uninstall_connector()) are distinct: the first removes the connector from the appliance’s self-agent by integer id, the second removes it from a named remote agent.