Prerequisite: Python version 3.11 or higher.

Install the Python SDK on your OS

pip install openagents-node-sdk

Import the SDK

from openagents import JobRunner,OpenAgentsNode,NodeConfig,RunnerConfig

Create a runner

  • Definition of a runner class:
# Define a runner
class MyRunner (JobRunner):
    def __init__(self):
        # Set the runner metadata, template and filter
        super().__init__(
            RunnerConfig(
                meta={ # ...

Metadata

Metadata are required information about the node. You can set them in the “meta” object.

The “kind” value is the job kind, you can use one from the NIP-90 standard jobs
or “5003” that is the custom OpenAgents generic job kind.

All the other fields are free to be filled in as you like.

meta={
    "kind": 5003,
    "name": "My Action",
    "description": "This is a new action",
    "tos": "https://example.com/tos",
    "privacy": "https://example.com/privacy",
    "picture": "https://example.com/icon.png",
    "tags": ["tag1","tag2"],
    "website": "https://example.com",
    "author": "John Doe",
},

The tags field might be used by other nodes to filter actions by type.

We currently use the following tags:

  • tool: if the action can be considered as a standalone tool for an LLM

But you can use any tag you like.

Filters

Filters are regular expressions used to filter the jobs that the node can execute.

You can check the protocol definition for a list of filters that you can use.

filter={
    "filterByKind": "5003",
    #AND
    "filterByRunOn": "my-new-action"
},

Jobs of kind 5003 always have a “run-on” parameter that is used to define which OpenAgents node should execute the job.

If you are writing a runner for a 5003 job kind, you should always use “filterByRunOn” with a custom name of you choice that you will use in the “run-on” parameter in the template (see below).

Templates

Runner templates are mustache templates based on NIP-01 and NIP-90 that define the structure of the event that must be sent to nostr to invoke the runner.

Inputs are defined as one ore more ["i"] tags:

["i", "{{value}}", "{{type}}", "",  "query"],

They need a “value”, a “type” and a “name or maker”.

As you can see value and type here are mustache’s tags, we will see how they are replaced in the next section.

“query” is the marker, it can be anything you want and its only purpose is to help you identify the input in the runner logic.

There can also be 0 or more parameters, prefixed with param, then the key (name) and the value:

["param","run-on", "my-new-action" ],

A param of type run-on is mandatory if you create a 5003 event and should be the same as the filterByRunOn in the filter.

Events can contain anything that is defined in the NIPs as they represent standard nostr events.

The full template will look something like this:

template="""
{
    "kind": {{meta.kind}},
    "created_at": {{sys.timestamp_seconds}},
    "tags": [
        ["param","run-on", "my-new-action" ],
        ["param", "k", "{{in.k}}"],
        ["output" , "{{in.outputType}}"],
        {{#in.queries}}
        ["i", "{{value}}", "{{type}}", "",  "query"],
        {{/in.queries}}
        ["expiration", "{{sys.expiration_timestamp_seconds}}"],
    ],
    "content":""
}"""

For more information on the mustache syntax, see the official documentation.

Sockets

As you saw in the template, we use mustache tags prefixed with in, out and sys, these match the keys in the sockets object.

You need to define the in and out socket layouts in the runner class:

sockets={
    "in": {
        "k": {
            "title": "MyNumber",
            "description":"This is a number",
            "type":"integer",
            "default": 0
        },
        "queries": {
            "title": "MyArray",
            "type":"array",
            "description":"This is an array of strings",
            "items": {
                "type":"string"
            }
        },
        "outputType": {
            "title": "Output Type",
            "type": "string",
            "description": "The output type of the event",
            "default": "application/json"
        }
    },
    "out": {
        "output": {
            "title": "Output",
            "type": "string",
            "description": "The output content"
        }
    }
}

This object defines the structure of the inputs and outputs of the runner and will be used by the software, that wish to interact with it, together with the template to create a nostr event.

The socket object is in JSON schema, you can check the official documentation for more details.

These values can be accessed in the template using the mustache syntax by prefixing the key with in or out as we’ve seen in the previous section.

The sys socket is defined by the environment, so you don’t have to define it in the sockets object, but you can use its values in the template:

  • sys.timestamp_seconds is the current timestamp in seconds
  • sys.expiration_timestamp_seconds is the expiration timestamp for the event in seconds

The runner logic

API documentation for the SDK methods.

The runner class

The init method intitializes the runner class:

async def init(self, node):
       # Initialize class
       pass

Execution methods

These methods receive as argument a ctx JobContext object. The ctx object contains several methods that can be used to get useful information related to the job (including inputs and parameters) and to comunicate with the pool’s api, see the documentation for more details.

canRun is a filter. Can be used to perform checks before deciding whether to run the job (bool):

async def canRun(self,ctx):
    # Custom job filtering logic
    return True

preRun is a hook that runs before run. It can be useful depending on the case:

async def preRun(self, ctx):
    # Do something before running the job
    pass

postRun executes some code after the main logic in run has been executed. It can be useful depending on the case:

async def postRun(self, ctx):
    # Do something after running the job
    pass

run is the main method, the one on which the node’s core logic must be implemented:

async def run(self,ctx):
    # Do something
    print("Running job",job.id)
    # Finish the job
    jobOutput=""
    return jobOutput

Parallel execution - Advanced

A single instance of the JobRunner is used to process each compatible job, by default this happens synchronously (ie. the next job will start after the end of the previous job). It is possible to configure the runner to run the jobs in parallel by calling self.setRunInParallel(True) inside the init method.

Note: Parallel runners should be written in valid non-blocking asyncio code.

Create the node

Now that you have defined the runner class, you can write the code to initialize and start the runner, you can do it in the same file:

# Initialize the node
myNode = OpenAgentsNode(NodeConfig(
    meta={
        "name": "My Node",
        "description": "This is a new node",
        "version": "1.0"
    }
))

name, description and version are required fields for the node metadata, you can set them as you like.

Next you need to register the runner

# Register the runner (you can register multiple runners)
myNode.registerRunner(MyRunner())

And finally start the node:

# Start the node
myNode.start()

Full code example

You can see a full example of a runner here.

Running the node

If you have followed up to this point, you should have a runner class and a node ready to be executed.

By default the node will connect to OpenAgents open pool (playground.openagents.com 6021 ssl), this can be changed by setting the following environment variables:*

POOL_ADDRESS="playground.openagents.com" # custom pool address
POOL_PORT="6021" #  custom pool port
POOL_SSL="true" # or "false"

To learn how to host your own pool check the Running a Pool documentation.

You can run the node with the following command:

python myNode.py