Tutorial: Build Your First Extension
In this tutorial you will build a complete Cirata Symphony extension in Python that includes a UI page, a microservice endpoint, a menu item, and persistent storage. By the end, you will understand the core extension development workflow.
Prerequisites
- A running Symphony instance
- An API key with full access (
cirata.>publish and subscribe) - Python 3.10 or later
- The Symphony Python library installed:
pip install cirata_symphony-*.whl
What You Will Build
A "Notes" extension that:
- Adds a page to the Symphony UI for viewing and creating notes
- Exposes a microservice endpoint for saving and listing notes
- Stores notes in a NATS JetStream key-value bucket
- Appears in the Symphony navigation menu
Step 1: Project Setup
Create a project directory:
mkdir notes-extension && cd notes-extension
Create a file called notes.py:
"""
Notes Extension for Symphony — Tutorial
"""
import asyncio
import json
import time
from cirata import symphony
from cirata.symphony.logging import configure_logging
configure_logging("notes")
Step 2: Define the UI Page
Add the UI page as a string. Symphony extensions provide React/TypeScript code that is transpiled and rendered in the browser:
page = """
import { useState, useEffect, useContext } from 'react'
import { SymphonyContext } from '@symphony'
export default function Notes() {
const ctx = useContext(SymphonyContext)
const [notes, setNotes] = useState([])
const [text, setText] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
ctx.setTitle({
"/": "Home",
"/notes": "Notes"
})
loadNotes()
}, [])
async function loadNotes() {
try {
const response = await ctx.invokeService('cirata.extensions.notes.list', '{}')
setNotes(JSON.parse(response) || [])
} catch (e) {
console.error('Failed to load notes:', e)
}
setLoading(false)
}
async function saveNote() {
if (!text.trim()) return
await ctx.invokeService('cirata.extensions.notes.save',
JSON.stringify({ text: text.trim(), timestamp: Date.now() }))
setText('')
loadNotes()
}
if (loading) return <p>Loading...</p>
return (
<div style={{ maxWidth: 600 }}>
<h1>Notes</h1>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveNote()}
placeholder="Write a note..."
style={{ flex: 1, padding: 8 }}
/>
<button onClick={saveNote}>Save</button>
</div>
{notes.length === 0
? <p>No notes yet. Write one above.</p>
: notes.map((note, i) => (
<div key={i} style={{
padding: 12, marginBottom: 8,
border: '1px solid #ddd', borderRadius: 4
}}>
<p style={{ margin: 0 }}>{note.text}</p>
<small style={{ color: '#888' }}>
{new Date(note.timestamp).toLocaleString()}
</small>
</div>
))
}
</div>
)
}
"""
Step 3: Implement the Microservice Handlers
Add handlers for listing and saving notes. These are NATS microservice endpoints:
# Global reference to the JetStream key-value bucket
kv = None
async def list_handler(req):
"""Return all saved notes as a JSON array."""
try:
notes = []
keys = await kv.keys()
for key in keys:
entry = await kv.get(key)
if entry and entry.value:
notes.append(json.loads(entry.value))
notes.sort(key=lambda n: n.get("timestamp", 0), reverse=True)
await req.respond(json.dumps(notes).encode())
except Exception:
await req.respond(b"[]")
async def save_handler(req):
"""Save a new note to the key-value store."""
try:
note = json.loads(req.data)
key = f"note:{int(time.time() * 1000)}"
await kv.put(key, json.dumps(note).encode())
await req.respond(json.dumps({"status": "saved", "key": key}).encode())
except Exception as e:
await req.respond(json.dumps({"error": str(e)}).encode())
Step 4: Wire Everything Together
Add the main() function that initializes the extension, registers all features, and starts operating:
async def main():
global kv
try:
async with symphony.Extension("Notes", "notes") as extension:
# Define capabilities so users know what permissions are needed
extension.add_capability("sub", "extensions.notes", "Notes Extension")
extension.add_capability("sub", "extensions.notes.list", "List notes")
extension.add_capability("pub", "extensions.notes.save", "Save notes")
if extension.token:
# Register the UI page and route
extension.add_resource("ui://notes", "text/symphony-jsx", page)
extension.add_route("/notes", "ui://notes")
# Add a menu item
extension.add_menu("Extensions", "Notes", "/notes", "fa-sticky-note")
# Create microservice endpoints
endpoints = await extension.add_endpoints(
name="Notes",
version="1.0.0",
description="Notes storage service"
)
group = endpoints.add_group(name="cirata.extensions.notes")
await group.add_endpoint(name="list", handler=list_handler)
await group.add_endpoint(name="save", handler=save_handler)
# Set up persistent storage
js = extension.client.js
kv = await js.create_key_value(bucket="notes_data")
await extension.operate()
except RuntimeError as e:
print(f"Unable to initialize: {e}")
if __name__ == "__main__":
asyncio.run(main())
Step 5: Run the Extension
Set your API token and run:
export SYMPHONY_TOKEN="<your-api-token>"
python notes.py
You should see log output indicating the extension has connected and registered with Symphony.
Step 6: Test It
- Open Symphony in your browser
- Look for Notes in the navigation menu
- Click it to open the Notes page
- Type a note and click Save
- The note should appear in the list
You can also test the microservice endpoint directly:
# Using the cirata CLI (if the extension exposes an OpenAPI spec)
# Or using the REST API proxy:
curl -H "Authorization: Bearer $SYMPHONY_TOKEN" \
https://your-symphony-instance.com/api/v1/extension/notes/list
Step 7: Add Help Content
Extensions can provide help documentation in markdown format. Add a help page:
help_page = """
# Notes Extension
The Notes extension provides a simple note-taking interface within Cirata Symphony.
## Usage
1. Navigate to **Notes** from the menu
2. Type a note in the input field
3. Press Enter or click Save
4. Notes are stored persistently and available across sessions
## API
- `cirata.extensions.notes.list` — Returns all saved notes as a JSON array
- `cirata.extensions.notes.save` — Saves a new note (expects `{"text": "...", "timestamp": ...}`)
"""
# Add this line after the other add_resource calls:
extension.add_resource("help://notes", "text/markdown", help_page)
Complete Source Code
The complete notes.py file:
"""
Notes Extension for Symphony — Tutorial
"""
import asyncio
import json
import time
from cirata import symphony
from cirata.symphony.logging import configure_logging
configure_logging("notes")
page = """
import { useState, useEffect, useContext } from 'react'
import { SymphonyContext } from '@symphony'
export default function Notes() {
const ctx = useContext(SymphonyContext)
const [notes, setNotes] = useState([])
const [text, setText] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
ctx.setTitle({ "/": "Home", "/notes": "Notes" })
loadNotes()
}, [])
async function loadNotes() {
try {
const response = await ctx.invokeService('cirata.extensions.notes.list', '{}')
setNotes(JSON.parse(response) || [])
} catch (e) { console.error('Failed to load notes:', e) }
setLoading(false)
}
async function saveNote() {
if (!text.trim()) return
await ctx.invokeService('cirata.extensions.notes.save',
JSON.stringify({ text: text.trim(), timestamp: Date.now() }))
setText('')
loadNotes()
}
if (loading) return <p>Loading...</p>
return (
<div style={{ maxWidth: 600 }}>
<h1>Notes</h1>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input value={text} onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveNote()}
placeholder="Write a note..." style={{ flex: 1, padding: 8 }} />
<button onClick={saveNote}>Save</button>
</div>
{notes.length === 0
? <p>No notes yet. Write one above.</p>
: notes.map((note, i) => (
<div key={i} style={{
padding: 12, marginBottom: 8,
border: '1px solid #ddd', borderRadius: 4
}}>
<p style={{ margin: 0 }}>{note.text}</p>
<small style={{ color: '#888' }}>
{new Date(note.timestamp).toLocaleString()}
</small>
</div>
))
}
</div>
)
}
"""
kv = None
async def list_handler(req):
try:
notes = []
keys = await kv.keys()
for key in keys:
entry = await kv.get(key)
if entry and entry.value:
notes.append(json.loads(entry.value))
notes.sort(key=lambda n: n.get("timestamp", 0), reverse=True)
await req.respond(json.dumps(notes).encode())
except Exception:
await req.respond(b"[]")
async def save_handler(req):
try:
note = json.loads(req.data)
key = f"note:{int(time.time() * 1000)}"
await kv.put(key, json.dumps(note).encode())
await req.respond(json.dumps({"status": "saved", "key": key}).encode())
except Exception as e:
await req.respond(json.dumps({"error": str(e)}).encode())
async def main():
global kv
try:
async with symphony.Extension("Notes", "notes") as extension:
extension.add_capability("sub", "extensions.notes", "Notes Extension")
extension.add_capability("sub", "extensions.notes.list", "List notes")
extension.add_capability("pub", "extensions.notes.save", "Save notes")
if extension.token:
extension.add_resource("ui://notes", "text/symphony-jsx", page)
extension.add_route("/notes", "ui://notes")
extension.add_menu("Extensions", "Notes", "/notes", "fa-sticky-note")
help_page = "# Notes\\n\\nA simple note-taking extension."
extension.add_resource("help://notes", "text/markdown", help_page)
endpoints = await extension.add_endpoints(
name="Notes", version="1.0.0",
description="Notes storage service")
group = endpoints.add_group(name="cirata.extensions.notes")
await group.add_endpoint(name="list", handler=list_handler)
await group.add_endpoint(name="save", handler=save_handler)
js = extension.client.js
kv = await js.create_key_value(bucket="notes_data")
await extension.operate()
except RuntimeError as e:
print(f"Unable to initialize: {e}")
if __name__ == "__main__":
asyncio.run(main())
Next: Add Observability
Now that your extension has a working UI, endpoint, and storage, you can add observability to gain visibility into its operation. Add metrics, logs, and traces with just a few lines:
from cirata.symphony.observability import Observability, SpanKind
# During setup, after add_endpoints:
obs = await Observability.enable(ext, endpoints, "notes")
# Wrap your handler with automatic tracing
@obs.instrumented_handler("save_note")
async def save_handler(req):
obs.increment_counter("notes.saved")
# ... existing handler code ...
The Observability Extension will automatically discover and collect your extension's telemetry. See Observability for the full guide.
Next Steps
- Python Extension Development—Complete Python extension reference
- Platform Functionality—All capabilities available to extensions
- Java Extension Development—Build extensions in Java
- Go Extension Development—Build extensions in Go
- Rust Extension Development—Build extensions in Rust