Tutorial: Your First Plugin¶
This tutorial shows the complete lifecycle of a Stepyard plugin - from scaffolding to running in a flow. You will build a plugin that sends a message to a Telegram chat.
What you'll learn:
- Create a plugin with
stepyard plugin init - Write a
@nodefunction - Validate inputs with type hints
- Install the plugin into a project
- Write a unit test with
stepyard.sdk.testing - Use the node in a flow
Estimated time: 15 minutes.
1. Scaffold the plugin¶
stepyard plugin init stepyard-plugin-telegram ./stepyard-plugin-telegram
cd stepyard-plugin-telegram
The scaffold creates:
stepyard-plugin-telegram/
├── pyproject.toml
├── README.md
├── src/
│ └── stepyard_plugin_telegram/
│ ├── __init__.py
│ └── nodes.py
└── tests/
└── test_nodes.py
2. Write the node¶
Open src/stepyard_plugin_telegram/nodes.py and replace it with:
import httpx
from stepyard.sdk import node, NodeResult
@node(name="telegram.send_message")
def send_message(
token: str, # (1)
chat_id: str,
text: str,
parse_mode: str = "HTML",
disable_notification: bool = False,
) -> NodeResult:
"""Send a message to a Telegram chat via the Bot API.
Args:
token: Telegram Bot API token (keep in env, not in YAML).
chat_id: Numeric chat ID or @username of the channel.
text: Message body. Supports HTML or Markdown depending on parse_mode.
parse_mode: "HTML" or "MarkdownV2". Default: "HTML".
disable_notification: Send silently (no sound/vibration).
"""
url = f"https://api.telegram.org/bot{token}/sendMessage"
response = httpx.post(
url,
json={
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode,
"disable_notification": disable_notification,
},
timeout=10,
)
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram API error: {data.get('description', 'unknown')}")
return NodeResult(
status="success",
output={
"message_id": data["result"]["message_id"],
"chat_id": data["result"]["chat"]["id"],
},
)
- Stepyard reads these type hints and builds a Pydantic validation model automatically. If a flow passes
chat_id: 123(int) where astris expected, validation fails before the function is called.
3. Declare the entry point¶
The pyproject.toml generated by plugin init already has the entry-point group. Make sure it lists your node file:
4. Write a unit test¶
Open tests/test_nodes.py:
import pytest
from unittest.mock import patch, MagicMock
from stepyard.sdk.testing import run_node
from stepyard_plugin_telegram.nodes import send_message
def test_send_message_success():
mock_response = MagicMock()
mock_response.json.return_value = {
"ok": True,
"result": {"message_id": 42, "chat": {"id": 123456}},
}
with patch("httpx.post", return_value=mock_response):
result = run_node( # (1)
send_message,
{"token": "bot-token", "chat_id": "123456", "text": "Hello from tests!"},
)
assert result.output["message_id"] == 42
assert result.output["chat_id"] == 123456
def test_send_message_api_error():
mock_response = MagicMock()
mock_response.json.return_value = {
"ok": False,
"description": "chat not found",
}
with patch("httpx.post", return_value=mock_response):
with pytest.raises(RuntimeError, match="Telegram API error"):
run_node(send_message, {"token": "t", "chat_id": "bad", "text": "hi"})
def test_requires_token():
"""Passing nothing should raise a validation error before any network call."""
with pytest.raises(Exception, match="token"):
run_node(send_message, {"chat_id": "123", "text": "hi"}) # token missing
run_node(synchronous) andinvoke_node(async) run your function through the same Pydantic validation andNodeContextinjection that the real engine uses - no mocking of Stepyard internals required. Access outputs viaresult.output.
Run the tests:
5. Install the plugin into your project¶
Go back to your Stepyard project and install the plugin:
Stepyard installs the package into .stepyard/env (an isolated virtualenv) and records it in stepyard.lock.
Verify the node is visible (tools list shows nodes; plugin list shows packages):
Node Source
─────────────────────────────────────────────────
shell.run builtin
http.request builtin
llm.generate builtin
telegram.send_message stepyard-plugin-telegram
6. Use it in a flow¶
# ... previous steps ...
- id: notify_success
if: ${{ steps.test.output.code == 0 }}
uses: telegram.send_message
with:
token: ${{ env.TELEGRAM_BOT_TOKEN }} # (1)
chat_id: ${{ env.TELEGRAM_CHAT_ID }}
text: |
✅ <b>CI passed</b>
Branch: ${{ env.BRANCH }}
Tests: ${{ steps.test.output.stdout }}
- Never hardcode tokens in YAML. Use
${{ env.VAR }}and pass real values through environment variables or a.envfile.
Run:
7. Iterate on the plugin¶
Installs into .stepyard/env are regular (non-editable) packages, so after you
change the plugin's source, re-run plugin add to pick up the new code and
re-discover its capabilities:
Confirm the node is registered with stepyard tools list.
What's next?¶
- Plugin SDK reference -
@trigger,StepExecutionHook,NodeResult,NodeContext, async nodes - Testing plugins -
fake_context,collect_trigger, coverage patterns - Real-world plugin examples - ETL, AI agents, Redis, AWS