Disciplines · Guides

Python-TypeScript Integration Patterns

1.

12sections3 minread

On this page

This guide documents the patterns and practices for integrating Python services (primarily Psyche domain) with TypeScript services in the Oshun platform.

Table of Contents#

  1. Overview
  2. Communication Patterns
  3. gRPC Integration
  4. Shared Type Definitions (Proto)
  5. REST API Integration
  6. Environment Variables
  7. Error Handling
  8. Authentication and Security
  9. Monitoring and Observability
  10. Best Practices

Overview#

The Oshun platform uses a polyglot architecture where:

  • TypeScript/Node.js: Primary language for web APIs, orchestration, and tooling
  • Python: ML/AI services, computer vision, audio processing, and scientific computing

Service Distribution#

Domain Language Purpose
Shared TypeScript Core utilities, auth, database, caching
Psyche Python AI avatar, voice, perception engines
Veritas Python + TS News verification, NLP, B2B SDK
Iris TypeScript AI assistant, conversation, reasoning
Lilith TypeScript Consciousness platform
Yemaya TypeScript Creative studio, asset management
Isis TypeScript Generative AI orchestration
Sophia TypeScript Research & knowledge management
Hathor TypeScript Worldbuilding simulation
Bellona TypeScript Game engine bridges
Tara TypeScript Meditation and mindfulness
Nyx TypeScript Astronomical education
Aja TypeScript Motion AI and animation
Aphrodite TypeScript Live streaming platform

Communication Patterns#

Pattern Selection Guide#

Use Case Recommended Pattern Latency Complexity
Request/Response (low lat) gRPC ~1-10ms Medium
Request/Response (simple) REST/HTTP ~10-100ms Low
Streaming data gRPC streaming N/A Medium
Fire-and-forget Event Bus (Redis) ~1-5ms Low
Long-running jobs Job Queue (BullMQ) N/A Medium
Real-time updates WebSocket ~1ms Medium

Architecture Overview#

text
┌─────────────────────────────────────────────────────────────────────┐
│                         API Gateway (Hono)                          │
│                        TypeScript / Node.js                         │
└─────────────────────────┬───────────────────────────────────────────┘
                          │
        ┌─────────────────┼─────────────────┐
        │                 │                 │
        ▼                 ▼                 ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ TypeScript    │ │  Python       │ │ TypeScript    │
│ Services      │ │  Services     │ │ Services      │
│ (Veritas,     │ │  (Psyche)     │ │ (Lilith,      │
│  etc.)        │ │               │ │  Hathor)      │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
        │                 │                 │
        └─────────────────┼─────────────────┘
                          │
                          ▼
              ┌───────────────────────┐
              │   Shared Services     │
              │  (Redis, PostgreSQL,  │
              │   Qdrant, S3)         │
              └───────────────────────┘

gRPC Integration#

Proto File Organization#

Proto files live in libs/proto/src/ and are organized by domain:

text
libs/proto/src/
├── common/
│   └── types.proto             # Shared types (UUID, Pagination, Error)
├── health/
│   └── health.proto            # Standard health checking
├── auth/
│   └── auth.proto              # Authentication service
├── isis/
│   └── isis.proto              # Isis generation service
├── sophia/
│   └── sophia.proto            # Sophia knowledge service
├── hathor/
│   └── hathor.proto            # Hathor worldbuilding service
├── bridge/
│   ├── blender.proto           # Blender engine bridge
│   ├── godot.proto             # Godot engine bridge
│   └── unreal.proto            # Unreal engine bridge
├── rendering/
│   └── rendering.proto         # Rendering service
├── ai/
│   └── ai.proto                # AI service definitions
└── [domain]/
    └── [service].proto

Note: Psyche domain proto definitions are planned but not yet created. When adding Psyche protos, follow the patterns established by existing domain protos above.

Proto Definition Standards#

protobuf
// libs/proto/src/psyche/avatar.proto
syntax = "proto3";

package oshun.psyche.avatar;

option go_package = "github.com/oshun/proto/psyche/avatar";

import "google/protobuf/timestamp.proto";
import "common/types.proto";

// Service definition
service AvatarService {
  // Generate a new avatar frame
  rpc GenerateFrame(GenerateFrameRequest) returns (GenerateFrameResponse);

  // Stream avatar frames
  rpc StreamFrames(StreamFramesRequest) returns (stream AvatarFrame);

  // Health check
  rpc Check(oshun.common.HealthCheckRequest) returns (oshun.common.HealthCheckResponse);
}

// Request message
message GenerateFrameRequest {
  string session_id = 1;
  AudioData audio = 2;
  ExpressionState expression = 3;
  RenderSettings settings = 4;
}

// Response message
message GenerateFrameResponse {
  bool success = 1;
  AvatarFrame frame = 2;
  oshun.common.Error error = 3;
}

// Data types
message AvatarFrame {
  bytes image_data = 1;
  string format = 2;  // "png", "jpeg", "webp"
  int32 width = 3;
  int32 height = 4;
  google.protobuf.Timestamp timestamp = 5;
  FrameMetadata metadata = 6;
}

TypeScript gRPC Client#

typescript
// libs/psyche/client/src/avatar-client.ts
import { credentials, Metadata } from '@grpc/grpc-js';
import { AvatarServiceClient } from '@oshun/proto/psyche/avatar';
import type {
  GenerateFrameRequest,
  GenerateFrameResponse,
} from '@oshun/proto/psyche/avatar';

export class AvatarClient {
  private client: AvatarServiceClient;

  constructor(address: string = 'localhost:50051') {
    this.client = new AvatarServiceClient(
      address,
      credentials.createInsecure()
    );
  }

  async generateFrame(
    request: GenerateFrameRequest,
    options?: { timeout?: number }
  ): Promise<GenerateFrameResponse> {
    const metadata = new Metadata();
    const deadline = new Date();
    deadline.setSeconds(deadline.getSeconds() + (options?.timeout ?? 30));

    return new Promise((resolve, reject) => {
      this.client.generateFrame(
        request,
        metadata,
        { deadline },
        (error, response) => {
          if (error) {
            reject(error);
          } else {
            resolve(response!);
          }
        }
      );
    });
  }

  streamFrames(request: StreamFramesRequest): AsyncIterable<AvatarFrame> {
    const stream = this.client.streamFrames(request);

    return {
      async *[Symbol.asyncIterator]() {
        for await (const frame of stream) {
          yield frame;
        }
      },
    };
  }
}

Python gRPC Server#

python
# services/psyche/avatar-engine/src/avatar_engine/grpc_server.py
import asyncio
from concurrent import futures
from typing import AsyncIterator

import grpc
from grpc_reflection.v1alpha import reflection

from oshun_proto.psyche import avatar_pb2, avatar_pb2_grpc
from oshun_proto.common import types_pb2


class AvatarServicer(avatar_pb2_grpc.AvatarServiceServicer):
    """gRPC service implementation for Avatar generation."""

    def __init__(self, avatar_engine: AvatarEngine):
        self.engine = avatar_engine

    async def GenerateFrame(
        self,
        request: avatar_pb2.GenerateFrameRequest,
        context: grpc.aio.ServicerContext,
    ) -> avatar_pb2.GenerateFrameResponse:
        """Generate a single avatar frame."""
        try:
            frame = await self.engine.generate_frame(
                session_id=request.session_id,
                audio_data=request.audio.data,
                expression=request.expression,
                settings=request.settings,
            )

            return avatar_pb2.GenerateFrameResponse(
                success=True,
                frame=avatar_pb2.AvatarFrame(
                    image_data=frame.data,
                    format=frame.format,
                    width=frame.width,
                    height=frame.height,
                ),
            )
        except Exception as e:
            context.set_code(grpc.StatusCode.INTERNAL)
            context.set_details(str(e))
            return avatar_pb2.GenerateFrameResponse(
                success=False,
                error=types_pb2.Error(
                    code="GENERATION_FAILED",
                    message=str(e),
                ),
            )

    async def StreamFrames(
        self,
        request: avatar_pb2.StreamFramesRequest,
        context: grpc.aio.ServicerContext,
    ) -> AsyncIterator[avatar_pb2.AvatarFrame]:
        """Stream avatar frames for real-time rendering."""
        async for frame in self.engine.stream_frames(
            session_id=request.session_id,
            audio_stream=request.audio_stream,
        ):
            if context.cancelled():
                break
            yield avatar_pb2.AvatarFrame(
                image_data=frame.data,
                format=frame.format,
                width=frame.width,
                height=frame.height,
            )


async def serve(port: int = 50051):
    """Start the gRPC server."""
    server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=10))

    # Add servicer
    avatar_engine = AvatarEngine()
    avatar_pb2_grpc.add_AvatarServiceServicer_to_server(
        AvatarServicer(avatar_engine), server
    )

    # Enable reflection for debugging
    SERVICE_NAMES = (
        avatar_pb2.DESCRIPTOR.services_by_name['AvatarService'].full_name,
        reflection.SERVICE_NAME,
    )
    reflection.enable_server_reflection(SERVICE_NAMES, server)

    server.add_insecure_port(f'[::]:{port}')
    await server.start()
    await server.wait_for_termination()


if __name__ == '__main__':
    asyncio.run(serve())

Code Generation#

Proto files are compiled to both TypeScript and Python:

bash
# Generate TypeScript types and client
nx build @oshun/proto

# Generate Python types (in each Python service)
cd services/psyche/avatar-engine
poetry run python -m grpc_tools.protoc \
  -I../../../libs/proto/src \
  --python_out=./src \
  --pyi_out=./src \
  --grpc_python_out=./src \
  ../../../libs/proto/src/psyche/avatar.proto

Shared Type Definitions (Proto)#

Common Types#

All services share common types defined in libs/proto/src/common/types.proto:

Type Purpose Usage
UUID Type-safe unique identifiers All entity IDs
PaginationRequest Pagination parameters List operations
PaginationMeta Pagination response metadata List responses
Error Standard error structure Error responses
FieldError Field-level validation errors Validation failures
HealthCheckRequest Health check parameters Health endpoints
HealthCheckResponse Health status Health responses

Type Mapping#

Proto Type TypeScript Type Python Type
string string str
int32/int64 number int
float/double number float
bool boolean bool
bytes Uint8Array bytes
repeated T T[] list[T]
map<K, V> Record<K, V> dict[K, V]
Timestamp Date datetime
optional T T | undefined T | None

Versioning Proto Files#

When making breaking changes:

  1. Non-breaking changes (add fields): Increment patch version
  2. Breaking changes: Create new package version
protobuf
// Old: package oshun.psyche.avatar.v1;
// New: package oshun.psyche.avatar.v2;

REST API Integration#

TypeScript Client for Python Services#

typescript
// libs/psyche/client/src/http-client.ts
import { httpClient } from '@oshun/http-client';
import type { AvatarSession, VoiceConfig } from '@psyche/contracts';

export class PsycheHttpClient {
  private baseUrl: string;

  constructor(baseUrl: string = process.env.PSYCHE_API_URL!) {
    this.baseUrl = baseUrl;
  }

  async createSession(config: SessionConfig): Promise<AvatarSession> {
    const response = await httpClient.post<AvatarSession>(
      `${this.baseUrl}/sessions`,
      config
    );
    return response.data;
  }

  async getVoices(): Promise<VoiceConfig[]> {
    const response = await httpClient.get<VoiceConfig[]>(
      `${this.baseUrl}/voices`
    );
    return response.data;
  }
}

Python Client for TypeScript Services#

python
# libs/psyche/common/src/common/clients/oshun_client.py
import httpx
from pydantic import BaseModel
from typing import TypeVar, Generic

T = TypeVar("T", bound=BaseModel)


class OshunServiceClient:
    """HTTP client for Oshun TypeScript services."""

    def __init__(
        self,
        base_url: str,
        api_key: str | None = None,
        timeout: float = 30.0,
    ):
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.headers = {"Content-Type": "application/json"}
        if api_key:
            self.headers["Authorization"] = f"Bearer {api_key}"

    async def get(self, path: str, response_model: type[T]) -> T:
        """GET request with response parsing."""
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            response = await client.get(
                f"{self.base_url}{path}",
                headers=self.headers,
            )
            response.raise_for_status()
            return response_model.model_validate(response.json())

    async def post(
        self,
        path: str,
        data: BaseModel,
        response_model: type[T],
    ) -> T:
        """POST request with request/response parsing."""
        async with httpx.AsyncClient(timeout=self.timeout) as client:
            response = await client.post(
                f"{self.base_url}{path}",
                headers=self.headers,
                json=data.model_dump(mode="json"),
            )
            response.raise_for_status()
            return response_model.model_validate(response.json())


# Usage example
class AuthClient(OshunServiceClient):
    """Client for @oshun/auth service."""

    def __init__(self):
        super().__init__(
            base_url=os.environ["AUTH_SERVICE_URL"],
            api_key=os.environ.get("AUTH_SERVICE_API_KEY"),
        )

    async def validate_token(self, token: str) -> TokenValidation:
        return await self.post(
            "/validate",
            TokenValidateRequest(token=token),
            TokenValidation,
        )

Environment Variables#

Naming Conventions#

Environment variables follow a consistent naming pattern:

text
<DOMAIN>_<SERVICE>_<SETTING>=value

Examples:

bash
# Service URLs
PSYCHE_AVATAR_URL=http://localhost:8001
PSYCHE_VOICE_URL=http://localhost:8002
VERITAS_API_URL=http://localhost:3001

# gRPC Ports
PSYCHE_AVATAR_GRPC_PORT=50051
PSYCHE_VOICE_GRPC_PORT=50052

# Database
PSYCHE_DATABASE_URL=postgresql://user:pass@localhost:5432/psyche
VERITAS_DATABASE_URL=postgresql://user:pass@localhost:5432/veritas

# Redis
REDIS_URL=redis://localhost:6379

# API Keys (shared)
ANTHROPIC_API_KEY=sk-ant-xxx
OPENAI_API_KEY=sk-xxx
ELEVENLABS_API_KEY=xxx

Configuration Files#

TypeScript Service#

typescript
// apps/veritas/api/src/config.ts
import { z } from 'zod';

const configSchema = z.object({
  // Server
  PORT: z.coerce.number().default(3001),
  HOST: z.string().default('0.0.0.0'),
  NODE_ENV: z
    .enum(['development', 'production', 'test'])
    .default('development'),

  // Database
  DATABASE_URL: z.string().url(),

  // Redis
  REDIS_URL: z.string().url(),

  // Psyche integration
  PSYCHE_AVATAR_URL: z.string().url(),
  PSYCHE_VOICE_URL: z.string().url(),
  PSYCHE_AVATAR_GRPC_PORT: z.coerce.number().default(50051),

  // API Keys
  ANTHROPIC_API_KEY: z.string().optional(),
  OPENAI_API_KEY: z.string().optional(),
});

export const config = configSchema.parse(process.env);

Python Service#

python
# services/psyche/avatar-engine/src/avatar_engine/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Avatar Engine configuration."""

    model_config = SettingsConfigDict(
        env_prefix="PSYCHE_AVATAR_",
        env_file=".env",
        case_sensitive=False,
    )

    # Server
    host: str = "0.0.0.0"
    port: int = 8001
    grpc_port: int = 50051
    workers: int = 4

    # Database
    database_url: str

    # Redis
    redis_url: str

    # Model paths
    model_path: str = "/models/avatar"
    cache_dir: str = "/cache"

    # GPU
    cuda_device: int = 0
    enable_cuda: bool = True

    # External services
    voice_service_url: str = "http://localhost:8002"
    auth_service_url: str = "http://localhost:3000"

    # API Keys
    anthropic_api_key: str | None = None


settings = Settings()

Shared Environment Files#

bash
# docker/docker-compose.dev.yml uses:
# .env                    - Root environment file
# .env.local              - Local overrides (gitignored)
# services/psyche/.env    - Psyche-specific defaults

Error Handling#

Error Code Standards#

Code Range Domain Example Codes
1000-1999 Auth AUTH_1001: Invalid token
2000-2999 Validation VAL_2001: Missing field
3000-3999 Psyche PSYCHE_3001: GPU unavailable
4000-4999 Veritas VERITAS_4001: Source not found
5000-5999 External EXT_5001: API rate limited

TypeScript Error Handling#

typescript
// libs/shared/errors/src/service-error.ts
export class ServiceError extends Error {
  constructor(
    public code: string,
    message: string,
    public statusCode: number = 500,
    public details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'ServiceError';
  }

  toJSON() {
    return {
      code: this.code,
      message: this.message,
      details: this.details,
    };
  }
}

// Usage
throw new ServiceError(
  'PSYCHE_3001',
  'Avatar generation failed: GPU memory exceeded',
  503,
  { requiredMemory: '24GB', available: '12GB' }
);

Python Error Handling#

python
# libs/psyche/common/src/common/errors.py
from dataclasses import dataclass
from typing import Any


@dataclass
class ServiceError(Exception):
    """Standard service error."""

    code: str
    message: str
    status_code: int = 500
    details: dict[str, Any] | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "code": self.code,
            "message": self.message,
            "details": self.details,
        }


class PsycheError(ServiceError):
    """Psyche domain errors."""

    def __init__(
        self,
        code: str,
        message: str,
        status_code: int = 500,
        details: dict[str, Any] | None = None,
    ):
        super().__init__(
            code=f"PSYCHE_{code}",
            message=message,
            status_code=status_code,
            details=details,
        )


# Usage
raise PsycheError(
    code="3001",
    message="GPU memory exceeded",
    status_code=503,
    details={"required": "24GB", "available": "12GB"},
)

Authentication and Security#

Token Flow#

text
┌──────────┐    ┌──────────────┐    ┌──────────────┐
│  Client  │───▶│  API Gateway │───▶│   @oshun/    │
│          │    │  (TypeScript)│    │    auth      │
└──────────┘    └──────┬───────┘    └──────────────┘
                       │
                       │ JWT Token
                       ▼
              ┌────────────────┐
              │ Python Service │
              │ (Psyche)       │
              │                │
              │ Validates JWT  │
              │ via shared     │
              │ secret/JWKS    │
              └────────────────┘

Python JWT Validation#

python
# services/psyche/api-gateway/src/api_gateway/auth.py
import jwt
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from functools import lru_cache

security = HTTPBearer()


@lru_cache()
def get_jwt_secret() -> str:
    """Get JWT secret from environment or secrets manager."""
    return os.environ["JWT_SECRET"]


async def verify_token(
    credentials: HTTPAuthorizationCredentials = Security(security),
) -> dict:
    """Verify JWT token and return payload."""
    try:
        payload = jwt.decode(
            credentials.credentials,
            get_jwt_secret(),
            algorithms=["HS256"],
            options={"verify_exp": True},
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=401,
            detail={"code": "AUTH_1002", "message": "Token expired"},
        )
    except jwt.InvalidTokenError as e:
        raise HTTPException(
            status_code=401,
            detail={"code": "AUTH_1001", "message": f"Invalid token: {e}"},
        )


# Usage in routes
@router.get("/sessions")
async def list_sessions(
    user: dict = Depends(verify_token),
):
    user_id = user["sub"]
    return await session_service.list_by_user(user_id)

Monitoring and Observability#

Shared Telemetry Standards#

All services emit telemetry in compatible formats:

Signal Format Collector
Traces OpenTelemetry/OTLP Jaeger/Tempo
Metrics Prometheus Prometheus/VictoriaMetrics
Logs JSON (structlog) Loki/Elasticsearch

TypeScript Tracing#

typescript
// libs/shared/tracing/src/index.ts
import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('veritas-api');

export async function withSpan<T>(
  name: string,
  fn: () => Promise<T>
): Promise<T> {
  return tracer.startActiveSpan(name, async (span) => {
    try {
      const result = await fn();
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
      throw error;
    } finally {
      span.end();
    }
  });
}

Python Tracing#

python
# services/psyche/avatar-engine/src/avatar_engine/tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

# Initialize
provider = TracerProvider()
processor = BatchSpanProcessor(
    OTLPSpanExporter(endpoint=os.environ.get("OTLP_ENDPOINT", "localhost:4317"))
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("psyche-avatar-engine")


# Usage
async def generate_frame(session_id: str, audio: bytes) -> Frame:
    with tracer.start_as_current_span("generate_frame") as span:
        span.set_attribute("session.id", session_id)
        span.set_attribute("audio.size", len(audio))

        # ... generation logic

        return frame

Best Practices#

DO#

  1. Use Proto for cross-language types

    • Define shared types in libs/proto/src/
    • Generate both TypeScript and Python bindings
    • Version proto files carefully
  2. Consistent error codes

    • Use domain-prefixed error codes
    • Document all error codes
    • Include actionable error messages
  3. Environment variable naming

    • Follow <DOMAIN>_<SERVICE>_<SETTING> pattern
    • Use Pydantic Settings in Python
    • Use Zod schemas in TypeScript
  4. Observability

    • Use OpenTelemetry for tracing
    • Export Prometheus metrics
    • Use structured JSON logging
  5. Health checks

    • Implement /health and /ready endpoints
    • Include dependency checks
    • Use standard gRPC health protocol

DON'T#

  1. Don't pass raw JSON between services

    python
    # Bad
    response = await client.post("/generate", json={"prompt": prompt})
    
    # Good
    response = await client.post(
        "/generate",
        GenerateRequest(prompt=prompt),
        GenerateResponse,
    )
    
  2. Don't hardcode service URLs

    typescript
    // Bad
    const url = 'http://localhost:8001/avatar';
    
    // Good
    const url = `${config.PSYCHE_AVATAR_URL}/avatar`;
    
  3. Don't ignore connection errors

    python
    # Bad
    async def call_service():
        return await client.get("/data")
    
    # Good
    async def call_service():
        try:
            return await client.get("/data")
        except httpx.ConnectError:
            raise ServiceUnavailableError("Avatar service unreachable")
        except httpx.TimeoutException:
            raise ServiceTimeoutError("Avatar service timeout")
    
  4. Don't mix sync and async in Python services

    python
    # Bad - blocks event loop
    def get_data():
        return requests.get(url).json()
    
    # Good
    async def get_data():
        async with httpx.AsyncClient() as client:
            response = await client.get(url)
            return response.json()