Skip to main content

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

  1. Open Symphony in your browser
  2. Look for Notes in the navigation menu
  3. Click it to open the Notes page
  4. Type a note and click Save
  5. 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